- Refactored navigation with grouped sections (Operations, Cultivation, Analytics, etc.) - Added RBAC-based navigation filtering by user role - Created DevTools panel for quick user switching during testing - Added collapsible sidebar sections on desktop - Mobile: bottom nav bar (4 items + More) with slide-up sheet - Enhanced seed data with [DEMO] prefix markers - Added multiple demo users: Owner, Manager, Cultivator, Worker - Fixed domain to runfoo.run - Added Audit Log and SOP Library pages to navigation - Created usePermissions hook and RoleBadge component
398 lines
22 KiB
TypeScript
398 lines
22 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import {
|
|
FileText, Search, Filter, Plus, ChevronRight, Clock, User, Check, X,
|
|
AlertCircle, Eye, Edit, Archive, Send, ThumbsUp, FolderOpen, Book,
|
|
ClipboardList, FileCheck, HelpCircle, MoreVertical, History, Download
|
|
} from 'lucide-react';
|
|
import { documentsApi, Document, DocumentType, DocumentStatus, DocumentVersion } from '../lib/documentsApi';
|
|
|
|
const TYPE_CONFIG: Record<DocumentType, { icon: React.ElementType; color: string; label: string }> = {
|
|
SOP: { icon: Book, color: 'text-indigo-500 bg-indigo-50 dark:bg-indigo-900/30', label: 'Standard Operating Procedure' },
|
|
POLICY: { icon: FileCheck, color: 'text-purple-500 bg-purple-50 dark:bg-purple-900/30', label: 'Policy' },
|
|
FORM: { icon: ClipboardList, color: 'text-blue-500 bg-blue-50 dark:bg-blue-900/30', label: 'Form' },
|
|
CHECKLIST: { icon: Check, color: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-900/30', label: 'Checklist' },
|
|
GUIDE: { icon: HelpCircle, color: 'text-amber-500 bg-amber-50 dark:bg-amber-900/30', label: 'Guide' },
|
|
OTHER: { icon: FileText, color: 'text-slate-500 bg-slate-50 dark:bg-slate-700', label: 'Document' }
|
|
};
|
|
|
|
const STATUS_CONFIG: Record<DocumentStatus, { color: string; label: string }> = {
|
|
DRAFT: { color: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300', label: 'Draft' },
|
|
PENDING_APPROVAL: { color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', label: 'Pending Approval' },
|
|
APPROVED: { color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400', label: 'Approved' },
|
|
ARCHIVED: { color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', label: 'Archived' }
|
|
};
|
|
|
|
export default function DocumentsPage() {
|
|
const [documents, setDocuments] = useState<Document[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [filterType, setFilterType] = useState<DocumentType | ''>('');
|
|
const [filterStatus, setFilterStatus] = useState<DocumentStatus | ''>('');
|
|
const [filterCategory, setFilterCategory] = useState('');
|
|
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
|
|
const [versions, setVersions] = useState<DocumentVersion[]>([]);
|
|
const [showVersions, setShowVersions] = useState(false);
|
|
const [pendingAcks, setPendingAcks] = useState<Document[]>([]);
|
|
|
|
useEffect(() => {
|
|
loadDocuments();
|
|
loadPendingAcks();
|
|
}, [filterType, filterStatus, filterCategory]);
|
|
|
|
async function loadDocuments() {
|
|
setLoading(true);
|
|
try {
|
|
const docs = await documentsApi.getDocuments({
|
|
type: filterType || undefined,
|
|
status: filterStatus || undefined,
|
|
category: filterCategory || undefined,
|
|
search: searchTerm || undefined
|
|
});
|
|
setDocuments(docs);
|
|
} catch (error) {
|
|
console.error('Failed to load documents:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function loadPendingAcks() {
|
|
try {
|
|
const pending = await documentsApi.getPendingAcks();
|
|
setPendingAcks(pending);
|
|
} catch (error) {
|
|
console.error('Failed to load pending acks:', error);
|
|
}
|
|
}
|
|
|
|
async function handleViewDocument(doc: Document) {
|
|
setSelectedDoc(doc);
|
|
try {
|
|
const vers = await documentsApi.getVersions(doc.id);
|
|
setVersions(vers);
|
|
} catch (error) {
|
|
console.error('Failed to load versions:', error);
|
|
}
|
|
}
|
|
|
|
async function handleAcknowledge(docId: string) {
|
|
try {
|
|
await documentsApi.acknowledgeDocument(docId);
|
|
setPendingAcks(prev => prev.filter(d => d.id !== docId));
|
|
if (selectedDoc?.id === docId) {
|
|
const updated = await documentsApi.getDocument(docId);
|
|
setSelectedDoc(updated);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to acknowledge:', error);
|
|
}
|
|
}
|
|
|
|
const categories = [...new Set(documents.map(d => d.category).filter(Boolean))];
|
|
|
|
const filteredDocs = documents.filter(doc => {
|
|
if (searchTerm && !doc.title.toLowerCase().includes(searchTerm.toLowerCase())) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const groupedDocs = filteredDocs.reduce((acc, doc) => {
|
|
const cat = doc.category || 'Uncategorized';
|
|
if (!acc[cat]) acc[cat] = [];
|
|
acc[cat].push(doc);
|
|
return acc;
|
|
}, {} as Record<string, Document[]>);
|
|
|
|
return (
|
|
<div className="space-y-6 pb-20">
|
|
{/* Header */}
|
|
<header className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
|
<Book className="text-indigo-500" />
|
|
SOP Library
|
|
</h2>
|
|
<p className="text-sm text-slate-500">Standard Operating Procedures & Compliance Documents</p>
|
|
</div>
|
|
<button className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium flex items-center gap-2 hover:bg-indigo-700 transition-colors">
|
|
<Plus size={16} />
|
|
New Document
|
|
</button>
|
|
</header>
|
|
|
|
{/* Pending Acknowledgements Banner */}
|
|
{pendingAcks.length > 0 && (
|
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
|
|
<div className="flex items-center gap-3">
|
|
<AlertCircle className="text-amber-600" size={20} />
|
|
<div className="flex-1">
|
|
<p className="font-medium text-amber-800 dark:text-amber-200">
|
|
{pendingAcks.length} document{pendingAcks.length > 1 ? 's require' : ' requires'} your acknowledgement
|
|
</p>
|
|
<p className="text-sm text-amber-600 dark:text-amber-400">
|
|
Please review and acknowledge these documents to remain compliant.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => handleViewDocument(pendingAcks[0])}
|
|
className="px-3 py-1.5 bg-amber-600 text-white text-sm font-medium rounded-lg hover:bg-amber-700"
|
|
>
|
|
Review Now
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap gap-3">
|
|
<div className="relative flex-1 min-w-[200px]">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
|
<input
|
|
type="text"
|
|
placeholder="Search documents..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-9 pr-4 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm focus:ring-2 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
<select
|
|
value={filterType}
|
|
onChange={(e) => setFilterType(e.target.value as DocumentType | '')}
|
|
className="px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm"
|
|
>
|
|
<option value="">All Types</option>
|
|
<option value="SOP">SOPs</option>
|
|
<option value="POLICY">Policies</option>
|
|
<option value="FORM">Forms</option>
|
|
<option value="CHECKLIST">Checklists</option>
|
|
<option value="GUIDE">Guides</option>
|
|
</select>
|
|
<select
|
|
value={filterStatus}
|
|
onChange={(e) => setFilterStatus(e.target.value as DocumentStatus | '')}
|
|
className="px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm"
|
|
>
|
|
<option value="">All Statuses</option>
|
|
<option value="APPROVED">Approved</option>
|
|
<option value="PENDING_APPROVAL">Pending</option>
|
|
<option value="DRAFT">Draft</option>
|
|
<option value="ARCHIVED">Archived</option>
|
|
</select>
|
|
{categories.length > 0 && (
|
|
<select
|
|
value={filterCategory}
|
|
onChange={(e) => setFilterCategory(e.target.value)}
|
|
className="px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm"
|
|
>
|
|
<option value="">All Categories</option>
|
|
{categories.map(cat => (
|
|
<option key={cat} value={cat}>{cat}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
|
|
{/* Documents Grid */}
|
|
{loading ? (
|
|
<div className="text-center py-20 text-slate-500">Loading documents...</div>
|
|
) : filteredDocs.length === 0 ? (
|
|
<div className="text-center py-20">
|
|
<FolderOpen className="mx-auto mb-3 text-slate-300" size={48} />
|
|
<p className="text-slate-500">No documents found</p>
|
|
<p className="text-sm text-slate-400 mt-1">Create your first document or adjust filters</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{Object.entries(groupedDocs).map(([category, docs]) => (
|
|
<div key={category}>
|
|
<h3 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2">
|
|
<FolderOpen size={14} />
|
|
{category}
|
|
<span className="text-xs font-normal text-slate-400">({docs.length})</span>
|
|
</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{docs.map(doc => {
|
|
const typeConfig = TYPE_CONFIG[doc.type];
|
|
const TypeIcon = typeConfig.icon;
|
|
const statusConfig = STATUS_CONFIG[doc.status];
|
|
const isPending = pendingAcks.some(p => p.id === doc.id);
|
|
|
|
return (
|
|
<div
|
|
key={doc.id}
|
|
onClick={() => handleViewDocument(doc)}
|
|
className={`bg-white dark:bg-slate-800 rounded-xl border transition-all cursor-pointer hover:shadow-md ${isPending
|
|
? 'border-amber-300 dark:border-amber-700 ring-2 ring-amber-100 dark:ring-amber-900/30'
|
|
: 'border-slate-200 dark:border-slate-700 hover:border-indigo-300 dark:hover:border-indigo-700'
|
|
}`}
|
|
>
|
|
<div className="p-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className={`p-2 rounded-lg ${typeConfig.color}`}>
|
|
<TypeIcon size={20} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-medium text-slate-900 dark:text-white truncate">
|
|
{doc.title}
|
|
</h4>
|
|
<p className="text-xs text-slate-500 mt-0.5">
|
|
{typeConfig.label} • v{doc.version}
|
|
</p>
|
|
</div>
|
|
{isPending && (
|
|
<span className="flex-shrink-0 w-2 h-2 bg-amber-500 rounded-full animate-pulse" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between mt-4 pt-3 border-t border-slate-100 dark:border-slate-700">
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusConfig.color}`}>
|
|
{statusConfig.label}
|
|
</span>
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
|
<Clock size={12} />
|
|
{new Date(doc.updatedAt).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Document Viewer Modal */}
|
|
{selectedDoc && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50" onClick={() => setSelectedDoc(null)}>
|
|
<div
|
|
className="bg-white dark:bg-slate-800 rounded-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden shadow-2xl flex flex-col"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
{/* Modal Header */}
|
|
<div className="p-4 border-b border-slate-200 dark:border-slate-700 flex justify-between items-start">
|
|
<div className="flex items-start gap-3">
|
|
<div className={`p-2 rounded-lg ${TYPE_CONFIG[selectedDoc.type].color}`}>
|
|
{(() => {
|
|
const Icon = TYPE_CONFIG[selectedDoc.type].icon;
|
|
return <Icon size={24} />;
|
|
})()}
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-bold text-slate-900 dark:text-white">{selectedDoc.title}</h3>
|
|
<p className="text-sm text-slate-500">
|
|
{TYPE_CONFIG[selectedDoc.type].label} • Version {selectedDoc.version}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setShowVersions(!showVersions)}
|
|
className={`p-2 rounded-lg transition-colors ${showVersions ? 'bg-indigo-100 text-indigo-600' : 'hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400'}`}
|
|
title="Version History"
|
|
>
|
|
<History size={18} />
|
|
</button>
|
|
<button
|
|
onClick={() => setSelectedDoc(null)}
|
|
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Document Status Bar */}
|
|
<div className="px-4 py-2 bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between text-sm">
|
|
<div className="flex items-center gap-4">
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_CONFIG[selectedDoc.status].color}`}>
|
|
{STATUS_CONFIG[selectedDoc.status].label}
|
|
</span>
|
|
{selectedDoc.effectiveDate && (
|
|
<span className="text-slate-500">
|
|
Effective: {new Date(selectedDoc.effectiveDate).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
{selectedDoc.expiresAt && (
|
|
<span className="text-amber-600">
|
|
Expires: {new Date(selectedDoc.expiresAt).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-slate-500">
|
|
{selectedDoc.createdBy && (
|
|
<span className="flex items-center gap-1">
|
|
<User size={14} />
|
|
{selectedDoc.createdBy.name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content Area */}
|
|
<div className="flex-1 overflow-hidden flex">
|
|
{/* Main Content */}
|
|
<div className={`flex-1 overflow-y-auto p-6 ${showVersions ? 'border-r border-slate-200 dark:border-slate-700' : ''}`}>
|
|
<div
|
|
className="prose prose-slate dark:prose-invert max-w-none"
|
|
dangerouslySetInnerHTML={{ __html: selectedDoc.content }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Version Sidebar */}
|
|
{showVersions && (
|
|
<div className="w-64 overflow-y-auto p-4 bg-slate-50 dark:bg-slate-900/50">
|
|
<h4 className="font-medium text-slate-900 dark:text-white mb-3">Version History</h4>
|
|
<div className="space-y-2">
|
|
{versions.map(ver => (
|
|
<div
|
|
key={ver.id}
|
|
className={`p-2 rounded-lg text-sm cursor-pointer ${ver.version === selectedDoc.version
|
|
? 'bg-indigo-100 dark:bg-indigo-900/30 border border-indigo-300 dark:border-indigo-700'
|
|
: 'bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 hover:border-indigo-300'
|
|
}`}
|
|
>
|
|
<div className="flex justify-between items-center">
|
|
<span className="font-medium">v{ver.version}</span>
|
|
<span className="text-xs text-slate-400">{new Date(ver.createdAt).toLocaleDateString()}</span>
|
|
</div>
|
|
{ver.changeNotes && (
|
|
<p className="text-xs text-slate-500 mt-1 truncate">{ver.changeNotes}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Modal Footer */}
|
|
<div className="p-4 border-t border-slate-200 dark:border-slate-700 flex justify-between items-center">
|
|
<div>
|
|
{selectedDoc.requiresAck && (
|
|
<button
|
|
onClick={() => handleAcknowledge(selectedDoc.id)}
|
|
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium flex items-center gap-2 hover:bg-emerald-700"
|
|
>
|
|
<Check size={16} />
|
|
I Acknowledge This Document
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button className="p-2 rounded-lg border border-slate-200 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-500">
|
|
<Download size={18} />
|
|
</button>
|
|
<button className="p-2 rounded-lg border border-slate-200 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-500">
|
|
<Edit size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|