feat: implement initial project, document, and resource management modules with routing, controllers, and UI components

This commit is contained in:
Ajjj
2026-08-05 03:26:06 +08:00
parent c03de704e2
commit 80fa96097c
16 changed files with 718 additions and 320 deletions

View File

@@ -30,7 +30,9 @@ const statusVariant = (s: string) => {
};
export default function Index({ contractors, filters }: Props) {
const { flash } = usePage<PageProps>().props;
const { flash, auth } = usePage<PageProps>().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) {
</SelectContent>
</Select>
}
actions={
actions={canCreateContractor ? (
<Link href={route('contractors.create')}>
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> New Contractor</Button>
</Link>
}
) : undefined}
/>
<CardContent>
<Table>

View File

@@ -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[] = '<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);
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();

View File

@@ -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<DocItem>;
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<any> | 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<HTMLScriptElement>('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<HTMLCanvasElement>(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<void>((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 (
<div className={`flex items-center justify-center bg-gray-50 border border-gray-100 animate-pulse ${className}`} style={{ height: '200px' }}>
<span className="text-[10px] font-sans text-gray-400 uppercase tracking-wider">Loading Preview...</span>
</div>
);
}
if (error) {
return (
<div className={`flex items-center justify-center bg-gray-50 border border-red-50 text-red-500 ${className}`} style={{ height: '200px' }}>
<div className="text-center p-4">
<File className="h-6 w-6 mx-auto mb-2 opacity-50 text-gray-400" />
<span className="text-[9px] font-sans uppercase tracking-wider block">Preview Error</span>
</div>
</div>
);
}
return (
<div className={`flex items-center justify-center bg-white border border-gray-100 overflow-hidden ${className}`} style={{ height: '200px' }}>
<canvas ref={canvasRef} className="max-h-full max-w-full object-contain" />
</div>
);
}
// Lightbox Modal for PDF Viewing
export function PDFLightboxModal({ url, title, isOpen, onClose }: { url: string; title: string; isOpen: boolean; onClose: () => void }) {
const canvasRef = useRef<HTMLCanvasElement>(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 (
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="max-w-6xl w-[95vw] sm:max-w-6xl h-[90vh] flex flex-col bg-white border border-gray-200 text-gray-900 rounded-lg shadow-2xl p-6 font-sans">
<DialogContent className="w-[96vw] max-w-[1400px] h-[92vh] flex flex-col overflow-hidden bg-white border border-gray-200 text-gray-900 rounded-lg shadow-2xl p-6 font-sans">
<DialogHeader className="border-b border-gray-100 pb-3 flex flex-row items-center justify-between flex-shrink-0">
<div>
<DialogTitle className="text-sm font-semibold tracking-wide text-gray-800">{title}</DialogTitle>
@@ -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 <PDFLightboxModal isOpen url={document.url} title={document.title} onClose={onClose} />;
}
if (isDocx(document.mime, document.fileName)) {
return (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="w-[96vw] max-w-[1400px] h-[92vh] flex flex-col overflow-hidden bg-white border border-gray-200 text-gray-900 rounded-lg shadow-2xl p-6 font-sans">
<DialogHeader className="border-b border-gray-100 pb-3 flex flex-row items-center justify-between flex-shrink-0">
<div>
<DialogTitle className="text-sm font-semibold tracking-wide text-gray-800">{document.title}</DialogTitle>
<p className="text-[9px] text-gray-400 mt-1 uppercase tracking-wider">Document Preview</p>
</div>
</DialogHeader>
<div className="flex flex-col gap-4 py-4 flex-1 min-h-0">
<div className="flex flex-wrap items-center justify-between gap-4 bg-gray-50 border border-gray-200 px-4 py-2 text-[10px] w-full rounded-md flex-shrink-0">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-8 text-[10px]" disabled>Previous</Button>
<span className="text-gray-500 tracking-wider">Page 1 of 1</span>
<Button variant="outline" size="sm" className="h-8 text-[10px]" disabled>Next</Button>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-8 text-[10px]" disabled={scale <= 0.5} onClick={() => setScale(value => Math.max(0.5, value - 0.25))}>Zoom Out</Button>
<span className="text-gray-600 tracking-wider w-12 text-center">{Math.round(scale * 100)}%</span>
<Button variant="outline" size="sm" className="h-8 text-[10px]" disabled={scale >= 1.5} onClick={() => setScale(value => Math.min(1.5, value + 0.25))}>Zoom In</Button>
</div>
<a href={document.downloadUrl}>
<Button variant="outline" size="sm" className="h-8 text-[10px]"><Download className="mr-1 h-3 w-3" /> Download</Button>
</a>
</div>
<div className="flex-1 min-h-0 overflow-auto rounded-md border border-gray-200 bg-gray-50 p-4">
<div
className="mx-auto flex-shrink-0"
style={{
width: `${816 * scale}px`,
height: `${paperHeight * scale}px`,
}}
>
<iframe
src={document.url}
title={document.title}
scrolling="no"
className="block w-[816px] origin-top-left rounded border-0 bg-white p-0 shadow-xl"
style={{ height: `${paperHeight}px`, transform: `scale(${scale})` }}
onLoad={(event) => {
const frameDocument = event.currentTarget.contentDocument;
const contentHeight = frameDocument?.documentElement.scrollHeight || 1120;
setPaperHeight(Math.max(1120, contentHeight));
}}
/>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="w-[96vw] max-w-[1400px] h-[92vh] flex flex-col overflow-hidden bg-white p-6">
<DialogHeader>
<DialogTitle>{document.title}</DialogTitle>
</DialogHeader>
<div className="flex flex-1 min-h-0 items-center justify-center rounded-lg border bg-gray-50 p-4">
{isImage(document.mime) ? (
<img src={document.url} alt={document.title} className="max-h-full max-w-full object-contain" />
) : isDocx(document.mime, document.fileName) ? (
<iframe src={document.url} title={document.title} className="h-full w-full rounded border-0 bg-white" />
) : (
<div className="flex flex-col items-center gap-3 text-center text-gray-500">
<File className="h-12 w-12 text-gray-400" />
<p className="text-sm">Preview is not available for this file type.</p>
<a href={document.downloadUrl}>
<Button><Download className="mr-2 h-4 w-4" /> Download File</Button>
</a>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
export default function Index({ documents, categories, projects, filters }: Props) {
const { flash, auth } = usePage<PageProps>().props;
const [search, setSearch] = useState(filters.search || '');
@@ -352,7 +374,7 @@ export default function Index({ documents, categories, projects, filters }: Prop
const [statusFilter, setStatusFilter] = useState(filters.status || 'all');
const [dialog, setDialog] = useState(false);
const [viewMode, setViewMode] = useState<'gallery' | 'list'>('gallery');
const [previewDoc, setPreviewDoc] = useState<{ url: string; title: string } | null>(null);
const [previewDoc, setPreviewDoc] = useState<PreviewDocument | null>(null);
const isContractorAdmin = (auth.user as any).user_type === 'Contractor Admin';
@@ -389,6 +411,16 @@ export default function Index({ documents, categories, projects, filters }: Prop
router.post(route('documents.approve', docId), { status }, { preserveScroll: true });
}
const openPreview = (doc: DocItem) => {
setPreviewDoc({
url: route('documents.preview', doc.ulid),
downloadUrl: route('documents.download', doc.ulid),
title: doc.title,
mime: doc.mime_type,
fileName: doc.current_file_name,
});
};
return (
<AuthenticatedLayout
header={
@@ -453,23 +485,25 @@ export default function Index({ documents, categories, projects, filters }: Prop
<div><Label className="text-gray-700">Title *</Label><Input className="bg-white text-gray-900" value={form.data.title} onChange={(e) => form.setData('title', e.target.value)} /></div>
<div className="grid grid-cols-2 gap-4">
<div><Label className="text-gray-700">Category *</Label>
<Select value={form.data.category_id} onValueChange={(v) => { if (v) form.setData('category_id', v); }}>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Select Category">
{form.data.category_id ? categories?.find(c => String(c.id) === String(form.data.category_id))?.name : undefined}
</SelectValue>
</SelectTrigger>
<SelectContent className="rounded-md">{categories?.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>)}</SelectContent>
</Select></div>
<select
value={form.data.category_id}
onChange={(e) => form.setData('category_id', e.target.value)}
className="flex h-8 w-full rounded-lg border border-input bg-white px-2.5 text-sm text-gray-900 outline-none focus:border-ring focus:ring-3 focus:ring-ring/50"
>
<option value="">Select Category</option>
{categories?.map(c => <option key={c.id} value={String(c.id)}>{c.name}</option>)}
</select>
</div>
<div><Label className="text-gray-700">Project</Label>
<Select value={form.data.project_id} onValueChange={(v) => { if (v) form.setData('project_id', v); }}>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Select Project">
{form.data.project_id ? projects?.find(p => String(p.id) === String(form.data.project_id))?.name : undefined}
</SelectValue>
</SelectTrigger>
<SelectContent className="rounded-md">{projects?.map(p => <SelectItem key={p.id} value={String(p.id)}>{p.name}</SelectItem>)}</SelectContent>
</Select></div>
<select
value={form.data.project_id}
onChange={(e) => form.setData('project_id', e.target.value)}
className="flex h-8 w-full rounded-lg border border-input bg-white px-2.5 text-sm text-gray-900 outline-none focus:border-ring focus:ring-3 focus:ring-ring/50"
>
<option value="">Select Project</option>
{projects?.map(p => <option key={p.id} value={String(p.id)}>{p.name}</option>)}
</select>
</div>
</div>
<div>
<Label className="text-gray-700">File *</Label>
@@ -529,13 +563,13 @@ export default function Index({ documents, categories, projects, filters }: Prop
<Button variant="ghost" size="icon-sm" className="text-red-600 hover:bg-red-50" title="Reject" onClick={() => approveDocument(doc.ulid, 'Rejected')}><XCircle className="h-4 w-4" /></Button>
</>
)}
{doc.mime_type?.includes('pdf') && (
{isPdf(doc.mime_type) && (
<Button
variant="ghost"
size="icon-sm"
className="text-gray-500 hover:text-gray-900 hover:bg-gray-100"
title="Preview"
onClick={() => setPreviewDoc({ url: route('documents.download', doc.ulid), title: doc.title })}
onClick={() => openPreview(doc)}
>
<Eye className="h-4 w-4" />
</Button>
@@ -554,38 +588,9 @@ export default function Index({ documents, categories, projects, filters }: Prop
{documents.data.map((doc) => (
<div
key={doc.id}
className="flex flex-col bg-white border border-gray-200/80 rounded-xl overflow-hidden transition-all duration-300 hover:border-blue-300 hover:shadow-lg hover:-translate-y-0.5 group"
className="flex cursor-pointer flex-col bg-white border border-gray-200/80 rounded-xl overflow-hidden transition-all duration-300 hover:border-blue-300 hover:shadow-lg hover:-translate-y-0.5 group"
onClick={() => openPreview(doc)}
>
<div className="relative aspect-video w-full overflow-hidden bg-gradient-to-br from-slate-50 to-gray-100 border-b border-gray-150 cursor-pointer" onClick={() => doc.mime_type?.includes('pdf') && setPreviewDoc({ url: route('documents.download', doc.ulid), title: doc.title })}>
{doc.mime_type?.includes('pdf') ? (
<PDFThumbnail url={route('documents.download', doc.ulid)} className="w-full h-full object-cover" />
) : isImage(doc.mime_type) ? (
<div className="flex flex-col items-center justify-center w-full h-full bg-blue-50/40 text-blue-500">
<Image className="h-10 w-10 stroke-[1.25] transition-transform duration-300 group-hover:scale-110" />
<span className="text-[11px] font-medium text-blue-600 mt-1 uppercase tracking-wider">Image</span>
</div>
) : (
<div className="flex flex-col items-center justify-center w-full h-full bg-slate-50 text-slate-400">
<File className="h-10 w-10 stroke-[1.25] transition-transform duration-300 group-hover:scale-110" />
<span className="text-[11px] font-medium text-slate-500 mt-1 uppercase tracking-wider">{doc.current_file_name?.split('.').pop() || 'File'}</span>
</div>
)}
<div className="absolute top-2 right-2 z-10">
<Badge variant="secondary" className="bg-white/90 backdrop-blur-sm text-gray-700 border border-gray-200/60 shadow-xs text-[10px] font-medium px-2 py-0.5">
{formatSize(doc.file_size)}
</Badge>
</div>
{doc.mime_type?.includes('pdf') && (
<div className="absolute inset-0 bg-slate-900/20 backdrop-blur-[2px] opacity-0 group-hover:opacity-100 flex items-center justify-center transition-all duration-200">
<Button size="sm" variant="secondary" className="h-8 rounded-lg border border-white/40 bg-white/95 text-slate-900 hover:bg-white text-xs font-semibold shadow-md">
<Eye className="mr-1.5 h-3.5 w-3.5 text-blue-600" /> Preview PDF
</Button>
</div>
)}
</div>
<div className="flex-1 p-4 flex flex-col justify-between space-y-4">
<div className="space-y-2.5">
<div className="flex items-start justify-between gap-2">
@@ -593,35 +598,30 @@ export default function Index({ documents, categories, projects, filters }: Prop
{doc.title}
</h3>
</div>
<div className="flex items-center justify-between gap-2 pt-0.5">
<Badge variant="outline" className="rounded-md border-gray-200 bg-slate-50/80 text-[10px] px-2 py-0.5 text-slate-600 font-medium tracking-tight">
{getCategoryName(doc.category)}
</Badge>
<Link href={route('documents.versions', doc.ulid)} className="text-[11px] font-semibold text-blue-600 hover:text-blue-700 bg-blue-50 px-2 py-0.5 rounded-md hover:bg-blue-100/80 transition-colors">
v{doc.version_count}
</Link>
</div>
<div className="text-xs text-gray-500 flex items-center justify-between border-t border-gray-100 pt-2.5">
<span className="truncate max-w-[150px] font-normal text-slate-500" title={doc.project?.name}>
Project: <span className="font-medium text-slate-700">{doc.project?.name || 'Global'}</span>
</span>
</div>
<div className="text-xs text-gray-500">
Publisher: <span className="font-medium text-slate-700">{doc.uploader?.name || 'Unknown'}</span>
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-100 pt-3">
<div className="flex items-center gap-1">
{doc.mime_type?.includes('pdf') && (
{(isPdf(doc.mime_type) || isImage(doc.mime_type) || isDocx(doc.mime_type, doc.current_file_name)) && (
<Button
variant="ghost"
size="icon-sm"
className="h-8 w-8 text-slate-500 hover:text-blue-600 hover:bg-blue-50/80 rounded-md transition-colors"
title="Preview"
onClick={() => setPreviewDoc({ url: route('documents.download', doc.ulid), title: doc.title })}
onClick={(event) => { event.stopPropagation(); openPreview(doc); }}
>
<Eye className="h-4 w-4" />
</Button>
)}
<a href={route('documents.download', doc.ulid)}>
<a href={route('documents.download', doc.ulid)} onClick={(event) => event.stopPropagation()}>
<Button
variant="ghost"
size="icon-sm"
@@ -631,7 +631,7 @@ export default function Index({ documents, categories, projects, filters }: Prop
<Download className="h-4 w-4" />
</Button>
</a>
<Link href={route('documents.versions', doc.ulid)}>
<Link href={route('documents.versions', doc.ulid)} onClick={(event) => event.stopPropagation()}>
<Button
variant="ghost"
size="icon-sm"
@@ -648,7 +648,7 @@ export default function Index({ documents, categories, projects, filters }: Prop
size="icon-sm"
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors"
title="Delete Document"
onClick={() => { if (confirm('Delete document and all versions?')) router.delete(route('documents.destroy', doc.ulid)); }}
onClick={(event) => { event.stopPropagation(); if (confirm('Delete document and all versions?')) router.delete(route('documents.destroy', doc.ulid)); }}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -672,16 +672,7 @@ export default function Index({ documents, categories, projects, filters }: Prop
</Card>
</div></div>
{previewDoc && (
<PDFLightboxModal
isOpen={!!previewDoc}
url={previewDoc.url}
title={previewDoc.title}
onClose={() => setPreviewDoc(null)}
/>
)}
{previewDoc && <DocumentPreviewModal document={previewDoc} onClose={() => setPreviewDoc(null)} />}
</AuthenticatedLayout>
);
}

View File

@@ -6,6 +6,7 @@ use Modules\DocumentManagement\Http\Controllers\DocumentController;
Route::middleware(['web', 'auth', 'permission:documents.access'])->group(function () {
Route::get('documents', [DocumentController::class, 'index'])->name('documents.index');
Route::post('documents', [DocumentController::class, 'upload'])->name('documents.upload');
Route::get('documents/{document}/preview', [DocumentController::class, 'preview'])->name('documents.preview');
Route::get('documents/{document}/download', [DocumentController::class, 'download'])->name('documents.download');
Route::get('documents/{document}/versions', [DocumentController::class, 'versions'])->name('documents.versions');
Route::post('documents/{document}/versions', [DocumentController::class, 'uploadVersion'])->name('documents.upload-version');

View File

@@ -383,42 +383,7 @@ class MaterialRequisitionController extends Controller
private function availableProjectsQuery()
{
$query = Project::withoutGlobalScope(\App\Scopes\TenantScope::class);
$user = auth()->user();
$isSiteOperationsUser = $user && $user->hasAnyRole([
'Site Technical',
'Construction Supervisor',
'Site Operations',
'Site Engineer',
'Site Supervisor',
]);
if ($user?->contractor_id) {
$contractorIds = DB::table('contractors')
->where('id', $user->contractor_id)
->orWhere('parent_id', $user->contractor_id)
->pluck('id')
->all();
$query->where(function ($projectQuery) use ($contractorIds, $user, $isSiteOperationsUser) {
$projectQuery
->whereIn('projects.contractor_id', $contractorIds)
->orWhereHas('contractors', function ($contractorQuery) use ($contractorIds) {
$contractorQuery->whereIn('contractors.id', $contractorIds);
});
if ($isSiteOperationsUser) {
$projectQuery->orWhereHas('personnel', function ($personnelQuery) use ($user) {
$personnelQuery->where('users.id', $user->id);
});
}
});
} elseif ($isSiteOperationsUser) {
$query->whereHas('personnel', function ($personnelQuery) use ($user) {
$personnelQuery->where('users.id', $user->id);
});
}
return $query;
// Project's TenantScope is the source of truth for project visibility.
return Project::query();
}
}

View File

@@ -586,40 +586,7 @@ class PurchaseOrderController extends Controller
private function availableProjectsQuery()
{
$query = Project::withoutGlobalScope(\App\Scopes\TenantScope::class);
$user = auth()->user();
$siteRoles = [
'Site Technical', 'Construction Supervisor', 'Site Operations',
'Site Engineer', 'Site Supervisor',
];
$isSiteOperationsUser = $user && $user->hasAnyRole($siteRoles);
if ($user?->contractor_id) {
$contractorIds = DB::table('contractors')
->where('id', $user->contractor_id)
->orWhere('parent_id', $user->contractor_id)
->pluck('id')
->all();
$query->where(function ($projectQuery) use ($contractorIds, $user, $isSiteOperationsUser) {
$projectQuery
->whereIn('projects.contractor_id', $contractorIds)
->orWhereHas('contractors', function ($contractorQuery) use ($contractorIds) {
$contractorQuery->whereIn('contractors.id', $contractorIds);
});
if ($isSiteOperationsUser) {
$projectQuery->orWhereHas('personnel', function ($personnelQuery) use ($user) {
$personnelQuery->where('users.id', $user->id);
});
}
});
} elseif ($isSiteOperationsUser) {
$query->whereHas('personnel', function ($personnelQuery) use ($user) {
$personnelQuery->where('users.id', $user->id);
});
}
return $query;
// Project's TenantScope is the source of truth for project visibility.
return Project::query();
}
}

View File

@@ -95,6 +95,22 @@ class ProjectController extends Controller
]);
}
public function discard(Request $request, Project $project)
{
abort_unless(
$request->user()->can('projects.delete') || $request->user()->can('delete projects'),
403
);
if ($project->current_wizard_step >= 7) {
return back()->with('error', 'Only draft projects can be discarded.');
}
$project->delete();
return redirect()->route('projects.index')->with('success', 'Draft project discarded successfully.');
}
public function store(Request $request)
{
$validated = $request->validate([

View File

@@ -72,7 +72,7 @@ export default function ProjectLayout({ project, allowedTransitions, children, c
>
<Head title={project ? `${project.name} - ${currentTab.charAt(0).toUpperCase() + currentTab.slice(1)}` : 'Select Project'} />
<div className={currentTab === 'tasks' ? "h-[calc(100vh-73px)] flex flex-col" : "py-6"}>
<div className={currentTab === 'tasks' ? "h-[calc(100vh-73px)] flex flex-col pt-2" : "py-6"}>
<div className={currentTab === 'tasks' ? "w-full h-full p-4 flex flex-col" : "mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"}>
{!project ? (
<div className="py-10">

View File

@@ -12,7 +12,7 @@ import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/Components/ui/table';
import { PaginatedData, PageProps } from '@/types';
import { Plus, Eye, Pencil, FolderKanban } from 'lucide-react';
import { Plus, Eye, Pencil, FolderKanban, Trash2 } from 'lucide-react';
import { FormEvent, useMemo, useState } from 'react';
interface Project {
@@ -177,17 +177,17 @@ export default function Index({ projects, history, drafts = [], filters, statuse
{activeTab === 'active' && (
<>
<Table>
<Table containerClassName="overflow-x-visible" className="table-fixed text-xs [&_th]:px-1.5 [&_td]:px-1.5">
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Project & Contractor</TableHead>
<TableHead>Client</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Contract Value</TableHead>
<TableHead className="text-right">Capitalization</TableHead>
<TableHead className="text-right">Progress</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead className="w-[11%]">Code</TableHead>
<TableHead className="w-[22%]">Project & Contractor</TableHead>
<TableHead className="w-[13%]">Client</TableHead>
<TableHead className="w-[11%] text-center">Status</TableHead>
<TableHead className="w-[14%] text-right">Contract Value</TableHead>
<TableHead className="w-[14%] text-right">Capitalization</TableHead>
<TableHead className="w-[10%] text-right">Progress</TableHead>
<TableHead className="w-[5%] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -200,27 +200,29 @@ export default function Index({ projects, history, drafts = [], filters, statuse
) : (
projects.data.map((project) => (
<TableRow key={project.id}>
<TableCell className="font-mono text-sm">{project.code}</TableCell>
<TableCell className="font-medium">
<div>{project.name}</div>
<TableCell className="truncate font-mono text-[11px]">{project.code}</TableCell>
<TableCell className="truncate font-medium">
<div className="truncate" title={project.name}>{project.name}</div>
{project.contractor && (
<div className="text-[11px] text-blue-600 font-normal flex items-center gap-1 mt-0.5">
<span>🏢 {project.contractor.company_name}</span>
</div>
)}
</TableCell>
<TableCell className="text-gray-500">{project.client_name || '-'}</TableCell>
<TableCell>
<Badge variant={statusVariant(project.status)}>
{statusLabel(project.status)}
</Badge>
<TableCell className="truncate text-gray-500" title={project.client_name || undefined}>{project.client_name || '-'}</TableCell>
<TableCell className="text-center">
<div className="flex justify-center">
<Badge variant={statusVariant(project.status)}>
{statusLabel(project.status)}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">
{formatCurrency(project.contract_value)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<div className="w-12 h-1.5 rounded-full bg-gray-200 overflow-hidden">
<div className="w-8 h-1.5 rounded-full bg-gray-200 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
project.is_over_budget ? 'bg-red-500' : project.capitalization_percentage >= 80 ? 'bg-amber-500' : 'bg-emerald-500'
@@ -236,8 +238,8 @@ export default function Index({ projects, history, drafts = [], filters, statuse
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<div className="w-16 h-2 rounded-full bg-gray-200 overflow-hidden">
<div className="flex items-center justify-end gap-1">
<div className="w-10 h-1.5 rounded-full bg-gray-200 overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${Math.min(Number(project.milestone_completion ?? project.completion_percentage ?? 0), 100)}%` }}
@@ -437,12 +439,27 @@ export default function Index({ projects, history, drafts = [], filters, statuse
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Link href={route('projects.wizard', [project.ulid, { step: project.current_wizard_step }])}>
<Button variant="outline" size="sm" className="h-8 border-emerald-500 text-emerald-600 hover:bg-emerald-50 text-xs">
Resume Setup
</Button>
</Link>
</div>
<Link href={route('projects.wizard', [project.ulid, { step: project.current_wizard_step }])}>
<Button variant="outline" size="sm" className="h-8 border-emerald-500 text-emerald-600 hover:bg-emerald-50 text-xs">
Resume Setup
</Button>
</Link>
{can('delete', 'projects') && (
<Button
variant="ghost"
size="icon-sm"
title="Discard draft project"
className="text-rose-500 hover:bg-rose-50 hover:text-rose-700"
onClick={() => {
if (confirm(`Discard draft project "${project.name}"? This action cannot be undone.`)) {
router.delete(route('projects.discard', project.ulid));
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))

View File

@@ -592,11 +592,6 @@ export default function Wizard({ project, step: currentStep, employees, projects
<div className="space-y-3">
{localTasks.map((t, idx) => (
<div key={idx} className="bg-slate-50/50 p-4 rounded-xl border border-slate-200/60 space-y-3 relative">
<div className="absolute right-3 top-3">
<Button variant="ghost" size="icon" onClick={() => removeTask(idx)} className="h-8 w-8 text-rose-500 hover:text-rose-700 hover:bg-rose-50">
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="md:col-span-2">
<Label className="text-[10px] text-slate-500">Task Name</Label>
@@ -607,24 +602,37 @@ export default function Wizard({ project, step: currentStep, employees, projects
className="h-8 text-xs border-slate-200 mt-1"
/>
</div>
<div>
<Label className="text-[10px] text-slate-500">Milestone</Label>
<Select
value={t.milestone_ulid}
onValueChange={val => updateTask(idx, 'milestone_ulid', val || '')}
items={localMilestones.map((m, mIdx) => ({ value: m.ulid || String(mIdx), label: m.name || `Milestone ${mIdx+1}` }))}
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1">
<Label className="text-[10px] text-slate-500">Milestone</Label>
<Select
value={t.milestone_ulid}
onValueChange={val => updateTask(idx, 'milestone_ulid', val || '')}
items={localMilestones.map((m, mIdx) => ({ value: m.ulid || String(mIdx), label: m.name || `Milestone ${mIdx+1}` }))}
>
<SelectTrigger className="h-8 text-xs border-slate-200 mt-1">
<SelectValue placeholder="Select Milestone" />
</SelectTrigger>
<SelectContent>
{localMilestones.map((m, mIdx) => (
<SelectItem key={mIdx} value={m.ulid || String(mIdx)}>
{m.name || `Milestone ${mIdx+1}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Delete task ${idx + 1}`}
title="Delete task"
onClick={() => removeTask(idx)}
className="mb-0 h-8 w-8 shrink-0 rounded-lg p-2 text-rose-500 hover:bg-rose-50 hover:text-rose-700"
>
<SelectTrigger className="h-8 text-xs border-slate-200 mt-1">
<SelectValue placeholder="Select Milestone" />
</SelectTrigger>
<SelectContent>
{localMilestones.map((m, mIdx) => (
<SelectItem key={mIdx} value={m.ulid || String(mIdx)}>
{m.name || `Milestone ${mIdx+1}`}
</SelectItem>
))}
</SelectContent>
</Select>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">

View File

@@ -5,6 +5,7 @@ use Modules\ProjectManagement\Http\Controllers\ProjectController;
Route::middleware(['web', 'auth', 'permission:projects.access'])->group(function () {
Route::resource('projects', ProjectController::class)->except(['destroy']);
Route::delete('projects/{project}/discard', [ProjectController::class, 'discard'])->name('projects.discard');
Route::patch('projects/{project}/transition', [ProjectController::class, 'transition'])->name('projects.transition');
// Project Wizard Routes

View File

@@ -21,6 +21,14 @@ class UserController extends Controller
public function index(Request $request): Response
{
$query = User::with(['roles', 'employeeProfile', 'customerProfile', 'contractor']);
// Super Admin and platform admin roles are never restricted by a legacy
// contractor link on their account.
$authUser = $request->user();
$isPlatformAdmin = $authUser && $authUser->hasAnyRole(['Super Admin', 'admin']);
if ($authUser && ! $isPlatformAdmin && ! is_null($authUser->contractor_id)) {
$query->where('contractor_id', $authUser->contractor_id);
}
if ($request->filled('search')) {
$search = $request->search;
@@ -448,6 +456,10 @@ class UserController extends Controller
{
$authUser = auth()->user();
if ($authUser->hasAnyRole(['Super Admin', 'admin'])) {
return;
}
if (! is_null($authUser->contractor_id) && $user->contractor_id !== $authUser->contractor_id) {
abort(redirect()->route('users.index')->with('error', 'You do not have permission to access this user.'));
}

View File

@@ -0,0 +1,128 @@
# GSB Construction ERP — Overall Workflow Audit
## Audit scope
This audit reviewed the application workflow from authentication and contractor onboarding through project setup, bidding, execution, materials, approvals, finance, documents, dashboards, and tenant isolation. It included source inspection and the Laravel feature suite. No application changes were made for the audit findings.
## Current verification status
| Check | Result |
|---|---|
| Contractor onboarding tests | PASS — 5 tests, 22 assertions |
| Feature suite after onboarding fix | FAIL — 63 passed, 2 failed, 45 pending; 229 assertions |
| TypeScript check | PASS |
| Vite production build | PASS |
| PHP syntax for changed onboarding files | PASS |
## Severity summary
| Severity | Finding | Status |
|---|---|---|
| High | Broad `Gate::before` admin bypass can treat contractor admins as unrestricted administrators | Open — security review required |
| Major | Cash advance E2E creation/query lifecycle does not produce the expected record | Open — D-002 |
| Major | Project wizard submission accepts empty approvers through fallback instead of returning the test-expected validation error | Open — D-003 |
| Medium | 45 tests are pending, leaving major workflow areas without automated coverage | Open |
| Low | PHPUnit doc-comment metadata is deprecated and will need conversion before PHPUnit 12 | Open |
## Workflow audit results
### 1. Authentication and onboarding
**Result: Partially passing.**
Contractor registration now passes its focused test file. The flow creates a pending contractor and inactive `Main Contractor Admin`, then blocks login until approval. The permission provisioning defect was fixed by resolving/creating `users.access` before assigning it.
Remaining checks: email/notification delivery, duplicate registration handling, password-change enforcement after approval, and rollback behavior when user creation fails.
### 2. Role and permission model
**Result: High-risk finding.**
`app/Providers/AppServiceProvider.php` returns `true` from `Gate::before()` for every user with `user_type === 'admin'`, and also for `Main Contractor Admin`. Contractor onboarding creates the primary contractor admin with `user_type = 'admin'`. This means role checks based on Gates can be bypassed even when the user is contractor-scoped.
Required audit action: test every sensitive create/update/approve/delete endpoint as a Main Contractor Admin and confirm both permission enforcement and tenant enforcement. The intended policy should distinguish platform administrators from contractor administrators.
### 3. Tenant and project visibility
**Result: Partially passing; needs full matrix execution.**
The project model uses tenant scoping and contractor hierarchy logic. Project visibility is intended to include the users contractor and permitted child contractors. The audit must still verify direct URL access, ULID guessing, pivot-linked projects, project personnel assignments, and unrelated contractor isolation across every module.
### 4. Project creation and wizard
**Result: Mostly passing with one workflow mismatch.**
Project creation, wizard validation, task/milestone creation, material estimates, labor, and equipment tests mostly pass. The remaining failure is D-003: `submitWizardForApproval()` treats an empty `approver_ids` array as permission to auto-select a higher-up, while the test expects validation failure.
This is a business-rule decision that must be resolved before implementation: either require explicit approvers or formally document and test the automatic fallback.
### 5. Bidding
**Result: Focused tests passing.**
The existing tests cover project-management creation of a draft package, publishing, and contractor denial of package management. The broader audit still needs scored evaluation, invitation visibility, duplicate/late submission handling, award state, cancellation, and contractor-admin versus contractor-user actions.
### 6. Tasks, milestones, and daily reports
**Result: Partial automated coverage.**
Task status/progress tests exist, and the UI includes status transitions and information-modal actions. Full workflow testing is still required for blocked/closed transitions, assignment restrictions, task costs, material allocation, daily report creation, report editing, and dashboard aggregation across multiple reports.
### 7. Materials, requisitions, purchase orders, and inventory
**Result: Partial coverage; high manual-test need.**
The workflow is present across requisitions, approval, purchase orders, receiving, payment, warehouses, movements, and transfers. The audit must verify that every project selector is contractor-scoped, approvers are found according to policy, delivered quantities update stock once, insufficient stock is rejected, and null project/material/supplier relations never cause frontend crashes.
### 8. Cash advances
**Result: Failing — D-002.**
The comprehensive E2E test submits a cash advance, expects a pending record, and then attempts approval. The created record cannot be found by amount immediately after submission. The root cause must be isolated between request validation, project access, model tenant scope, database creation, and the test query. The flow must also verify direct approval without an approval-chain record, no self-approval, correct approver roles, and contractor isolation.
### 9. Invoices and retention
**Result: Focused flow passes; broader coverage required.**
Invoice approval and retention behavior is covered by the current comprehensive test. Additional checks are needed for duplicate retention holds, rejection, partial payment, project access, null project rendering, approval-list visibility, and idempotent retries.
### 10. Dashboards and shared UI
**Result: Build and basic dashboard tests pass.**
Dashboard role routing, project status counting, resource aggregation, and role-specific layouts compile and have basic tests. The audit still requires database-to-UI comparisons for every role and contractor scope. Resource aggregation must explicitly define whether daily labor/equipment rows are historical totals or current snapshots, because summing daily snapshots can double-count deployed resources.
### 11. Documents and technical records
**Result: Requires workflow execution.**
Verify upload, categorization, download, project relation, contractor isolation, missing-file handling, and permission checks. Confirm that direct route access is denied even when sidebar links are hidden.
## Confirmed automated failures
### D-002 — Cash advance record missing after submission
- Test: `ComprehensiveSystemE2ETest::test_e2e_cash_advance_full_lifecycle_and_security_rules`
- Failure: `CashAdvance::where('amount', 750.50)->firstOrFail()` finds no result.
- Impact: cash advance lifecycle cannot be tested beyond submission.
- Next investigation: inspect response/session, project tenant scope, `CashAdvance::create`, database row count, and model global scope under the supervisor account.
### D-003 — Empty approver list follows fallback instead of validation
- Test: `ProjectWizardFlowTest::submit_fails_without_approvers`
- Failure: expected session validation error for `approver_ids` is absent.
- Impact: final initialization behavior is ambiguous and may create approval chains with an unintended approver.
- Next decision: explicit approvers required versus documented automatic higher-up fallback.
## Recommended remediation order
1. Resolve the Gate bypass for contractor administrators and add endpoint-level authorization tests.
2. Isolate and fix D-002, then test direct cash advance approval and tenant scope.
3. Decide and implement the D-003 approver policy, then update the test to match the accepted rule.
4. Replace 45 pending tests with executable coverage for high-risk workflows.
5. Convert PHPUnit doc-comment metadata to attributes before PHPUnit 12.
6. Run the full suite, browser workflow, tenant-isolation matrix, and production build as final verification.
## Audit conclusion
The system has a working foundation and the latest frontend/backend build is healthy, but it is not yet workflow-clean for sign-off. Contractor onboarding is now passing. Authorization boundaries, cash advance persistence, project approval semantics, and the large pending-test set must be resolved before the whole-system workflow can be considered reliable.

View File

@@ -2,12 +2,12 @@ import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
function Table({ className, containerClassName, ...props }: React.ComponentProps<"table"> & { containerClassName?: string }) {
return (
<div
data-slot="table-container"
className={cn("relative w-full overflow-x-auto", containerClassName)}
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}

View File

@@ -0,0 +1,151 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Modules\DocumentManagement\Models\Document;
use Modules\DocumentManagement\Models\DocumentCategory;
use Spatie\Permission\Models\Permission;
use Tests\TestCase;
class DocumentMediaPreviewTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected DocumentCategory $category;
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
$this->user = User::factory()->create(['status' => 'active']);
Permission::firstOrCreate(['name' => 'documents.access', 'guard_name' => 'web']);
$this->user->givePermissionTo('documents.access');
$this->category = DocumentCategory::create(['name' => 'Test Documents']);
}
public function test_pdf_upload_is_stored_and_available_as_inline_preview(): void
{
$document = $this->upload('plan.pdf', 'application/pdf');
$response = $this->actingAs($this->user)->get(route('documents.preview', $document));
$response->assertOk();
$response->assertHeader('Content-Type', 'application/pdf');
$response->assertHeader('Content-Disposition', 'inline; filename="plan.pdf"');
Storage::disk('local')->assertExists($document->current_file_path);
}
public function test_image_upload_is_stored_and_available_as_inline_preview(): void
{
$file = UploadedFile::fake()->image('site-photo.jpg', 640, 480);
$document = $this->uploadFile($file);
$response = $this->actingAs($this->user)->get(route('documents.preview', $document));
$response->assertOk();
$response->assertHeader('Content-Type', 'image/jpeg');
$response->assertHeader('Content-Disposition', 'inline; filename="site-photo.jpg"');
Storage::disk('local')->assertExists($document->current_file_path);
}
public function test_docx_upload_is_stored_and_downloadable(): void
{
$document = $this->upload(
'specification.docx',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
);
$this->actingAs($this->user)
->get(route('documents.download', $document))
->assertOk()
->assertHeader('Content-Disposition', 'attachment; filename=specification.docx');
Storage::disk('local')->assertExists($document->current_file_path);
}
public function test_valid_docx_is_rendered_as_html_preview(): void
{
$path = tempnam(sys_get_temp_dir(), 'document-preview-');
$archive = new \ZipArchive();
$archive->open($path, \ZipArchive::CREATE);
$archive->addFromString('word/document.xml', '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Previewable document content</w:t></w:r></w:p><w:tbl><w:tr><w:tc><w:p><w:r><w:t>Header</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>Value</w:t></w:r></w:p></w:tc></w:tr></w:tbl></w:body></w:document>');
$archive->close();
$storedPath = 'documents/contracts/previewable.docx';
Storage::disk('local')->put($storedPath, file_get_contents($path));
unlink($path);
$document = Document::create([
'title' => 'Previewable DOCX',
'category_id' => $this->category->id,
'status' => 'Approved',
'uploaded_by' => $this->user->id,
'current_file_path' => $storedPath,
'current_file_name' => 'previewable.docx',
'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'file_size' => Storage::disk('local')->size($storedPath),
'version_count' => 1,
]);
$this->actingAs($this->user)
->get(route('documents.preview', $document))
->assertOk()
->assertHeader('Content-Type', 'text/html; charset=UTF-8')
->assertSee('Previewable document content')
->assertSee('<table>', false)
->assertSee('Header')
->assertSee('Value')
->assertSee('width:8.5in');
}
public function test_existing_public_disk_pdf_is_available_as_inline_preview(): void
{
Storage::fake('public');
$path = 'bid-submissions/legacy-submission.pdf';
Storage::disk('public')->put($path, '%PDF-1.4 legacy test file');
$document = Document::create([
'title' => 'Legacy Bid Submission',
'category_id' => $this->category->id,
'status' => 'Approved',
'uploaded_by' => $this->user->id,
'current_file_path' => $path,
'current_file_name' => 'legacy-submission.pdf',
'mime_type' => 'application/pdf',
'file_size' => 24,
'version_count' => 1,
]);
$this->actingAs($this->user)
->get(route('documents.preview', $document))
->assertOk()
->assertHeader('Content-Type', 'application/pdf');
}
private function upload(string $name, string $mimeType): Document
{
return $this->uploadFile(UploadedFile::fake()->create($name, 10, $mimeType));
}
private function uploadFile(UploadedFile $file): Document
{
$this->actingAs($this->user)
->from(route('documents.index'))
->post(route('documents.upload'), [
'title' => pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME),
'category_id' => $this->category->id,
'description' => 'Media preview test file',
'file' => $file,
])
->assertRedirect(route('documents.index'));
return Document::query()->latest('id')->firstOrFail();
}
}

View File

@@ -474,6 +474,31 @@ class ProjectWizardFlowTest extends TestCase
);
}
/** @test */
public function admin_can_discard_a_draft_project(): void
{
$draft = Project::factory()->create(['current_wizard_step' => 3]);
$this->actingAs($this->admin)
->delete(route('projects.discard', $draft))
->assertRedirect(route('projects.index'))
->assertSessionHas('success');
$this->assertSoftDeleted('projects', ['id' => $draft->id]);
}
/** @test */
public function completed_wizard_projects_cannot_be_discarded(): void
{
$project = Project::factory()->create(['current_wizard_step' => 7]);
$this->actingAs($this->admin)
->delete(route('projects.discard', $project))
->assertSessionHas('error');
$this->assertDatabaseHas('projects', ['id' => $project->id, 'deleted_at' => null]);
}
// ─── Status Transitions ──────────────────────────────────────────────────
/** @test */