added invoice viweing on event request management

This commit is contained in:
chark1es 2025-03-11 02:33:31 -07:00
parent 1e98df9c53
commit e45f4a777f
2 changed files with 531 additions and 1 deletions

View file

@ -4,6 +4,7 @@ import toast from 'react-hot-toast';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
import { FileManager } from '../../../scripts/pocketbase/FileManager';
// Extended EventRequest interface with additional properties needed for this component
interface ExtendedEventRequest extends SchemaEventRequest {
@ -12,6 +13,7 @@ interface ExtendedEventRequest extends SchemaEventRequest {
email: string;
};
invoice_data?: string | any;
invoice_files?: string[]; // Array of invoice file IDs
}
interface EventRequestDetailsProps {
@ -20,8 +22,404 @@ interface EventRequestDetailsProps {
onStatusChange: (id: string, status: "submitted" | "pending" | "completed" | "declined") => Promise<void>;
}
// File preview modal component
interface FilePreviewModalProps {
isOpen: boolean;
onClose: () => void;
collectionName: string;
recordId: string;
fileName: string;
displayName: string;
}
const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
isOpen,
onClose,
collectionName,
recordId,
fileName,
displayName
}) => {
const [fileUrl, setFileUrl] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [fileType, setFileType] = useState<'image' | 'pdf' | 'other'>('other');
useEffect(() => {
const loadFile = async () => {
if (!isOpen) return;
setIsLoading(true);
setError(null);
try {
const fileManager = FileManager.getInstance();
// Get file URL with token for secure access
const url = await fileManager.getFileUrlWithToken(
collectionName,
recordId,
fileName,
true // Use token for secure access
);
setFileUrl(url);
// Determine file type based on extension
const extension = fileName.split('.').pop()?.toLowerCase() || '';
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension)) {
setFileType('image');
} else if (extension === 'pdf') {
setFileType('pdf');
} else {
setFileType('other');
}
} catch (err) {
console.error('Error loading file:', err);
setError('Failed to load file. Please try again.');
} finally {
setIsLoading(false);
}
};
loadFile();
}, [isOpen, collectionName, recordId, fileName]);
const handleDownload = async () => {
try {
const fileManager = FileManager.getInstance();
const blob = await fileManager.downloadFile(collectionName, recordId, fileName);
// Create a download link
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = displayName || fileName;
document.body.appendChild(a);
a.click();
// Clean up
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('File downloaded successfully');
} catch (err) {
console.error('Error downloading file:', err);
toast.error('Failed to download file');
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm z-50 flex items-center justify-center p-4 overflow-y-auto">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="bg-base-200 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"
>
{/* Header */}
<div className="bg-base-300 p-4 flex justify-between items-center">
<h3 className="text-xl font-bold truncate max-w-[80%]">{displayName || fileName}</h3>
<div className="flex items-center gap-2">
<button
className="btn btn-sm btn-outline"
onClick={handleDownload}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download
</button>
<button
className="btn btn-sm btn-circle"
onClick={onClose}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="flex-grow overflow-auto p-4 flex items-center justify-center bg-base-300/30">
{isLoading ? (
<div className="flex flex-col items-center justify-center p-8">
<div className="loading loading-spinner loading-lg"></div>
<p className="mt-4">Loading file...</p>
</div>
) : error ? (
<div className="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>{error}</span>
</div>
) : (
<>
{fileType === 'image' && (
<div className="max-h-full max-w-full">
<img
src={fileUrl}
alt={displayName || fileName}
className="max-h-[70vh] max-w-full object-contain"
onError={() => setError('Failed to load image')}
/>
</div>
)}
{fileType === 'pdf' && (
<iframe
src={`${fileUrl}#toolbar=0`}
className="w-full h-[70vh]"
title={displayName || fileName}
></iframe>
)}
{fileType === 'other' && (
<div className="flex flex-col items-center justify-center p-8 text-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 mb-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="mb-4">This file type cannot be previewed directly.</p>
<button
className="btn btn-primary"
onClick={handleDownload}
>
Download File
</button>
</div>
)}
</>
)}
</div>
</motion.div>
</div>
);
};
// File preview component
const FilePreview: React.FC<{
fileUrl: string,
fileName: string,
collectionName: string,
recordId: string,
originalFileName: string
}> = ({
fileUrl,
fileName,
collectionName,
recordId,
originalFileName
}) => {
const [isImage, setIsImage] = useState<boolean>(false);
const [displayName, setDisplayName] = useState<string>(fileName);
const [error, setError] = useState<boolean>(false);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
useEffect(() => {
// Reset error state when fileUrl changes
setError(false);
// Check if the file is an image based on extension
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
const extension = fileUrl.split('.').pop()?.toLowerCase() || '';
setIsImage(imageExtensions.includes(extension));
// Try to extract a better file name from the URL
try {
// Extract the file name from the URL path
const urlParts = fileUrl.split('/');
const lastPart = urlParts[urlParts.length - 1];
// If the last part contains a query string, remove it
const cleanName = lastPart.split('?')[0];
// Try to decode the URL to get a readable name
const decodedName = decodeURIComponent(cleanName);
// If we have a valid name that's different from the ID, use it
if (decodedName && decodedName.length > 0 && decodedName !== fileName) {
setDisplayName(decodedName);
}
} catch (e) {
// If anything goes wrong, just use the provided fileName
console.error('Error parsing file name:', e);
}
}, [fileUrl, fileName]);
// Handle image load error
const handleImageError = () => {
setIsImage(false);
setError(true);
};
// Open the file preview modal
const openModal = () => {
setIsModalOpen(true);
};
// Close the file preview modal
const closeModal = () => {
setIsModalOpen(false);
};
// If there's an error with the file, show a fallback
if (error) {
return (
<div className="border rounded-lg overflow-hidden bg-base-300/30 p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="text-sm truncate max-w-[80%]">{displayName}</span>
</div>
<button
onClick={openModal}
className="btn btn-xs btn-ghost"
>
View
</button>
</div>
{/* File Preview Modal */}
<FilePreviewModal
isOpen={isModalOpen}
onClose={closeModal}
collectionName={collectionName}
recordId={recordId}
fileName={originalFileName}
displayName={displayName}
/>
</div>
);
}
return (
<div className="border rounded-lg overflow-hidden bg-base-300/30">
{isImage ? (
<div className="flex flex-col">
<div className="relative aspect-video cursor-pointer" onClick={openModal}>
<img
src={fileUrl}
alt={displayName}
className="object-contain w-full h-full"
onError={handleImageError} // Fallback if not an image
/>
</div>
<div className="p-2 flex justify-between items-center">
<span className="text-sm truncate max-w-[80%]" title={displayName}>{displayName}</span>
<button
onClick={openModal}
className="btn btn-xs btn-ghost"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</div>
</div>
) : (
<div className="p-3 flex justify-between items-center">
<div className="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-sm truncate max-w-[80%]" title={displayName}>{displayName}</span>
</div>
<button
onClick={openModal}
className="btn btn-xs btn-ghost"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</div>
)}
{/* File Preview Modal */}
<FilePreviewModal
isOpen={isModalOpen}
onClose={closeModal}
collectionName={collectionName}
recordId={recordId}
fileName={originalFileName}
displayName={displayName}
/>
</div>
);
};
// Separate component for AS Funding tab to isolate any issues
const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }) => {
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState<boolean>(false);
const [selectedFile, setSelectedFile] = useState<{ name: string, displayName: string }>({ name: '', displayName: '' });
// Helper function to safely get file extension
const getFileExtension = (filename: string): string => {
if (!filename || typeof filename !== 'string') return '';
const parts = filename.split('.');
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
};
// Helper function to check if a file is an image
const isImageFile = (filename: string): boolean => {
const ext = getFileExtension(filename);
return ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext);
};
// Helper function to check if a file is a PDF
const isPdfFile = (filename: string): boolean => {
return getFileExtension(filename) === 'pdf';
};
// Helper function to get a friendly display name from a filename
const getFriendlyFileName = (filename: string, maxLength: number = 20): string => {
if (!filename || typeof filename !== 'string') return 'Unknown File';
// Remove any path information if present
let name = filename;
if (name.includes('/')) {
name = name.split('/').pop() || name;
}
// Remove any URL parameters if present
if (name.includes('?')) {
name = name.split('?')[0];
}
// Try to decode the filename if it's URL encoded
try {
name = decodeURIComponent(name);
} catch (e) {
// If decoding fails, just use the original name
}
// If the name is too long, truncate it and add ellipsis
if (name.length > maxLength) {
// Get the extension
const ext = getFileExtension(name);
if (ext) {
// Keep the extension and truncate the name
const nameWithoutExt = name.substring(0, name.length - ext.length - 1);
const truncatedName = nameWithoutExt.substring(0, maxLength - ext.length - 4); // -4 for the ellipsis and dot
return `${truncatedName}...${ext}`;
} else {
// No extension, just truncate
return `${name.substring(0, maxLength - 3)}...`;
}
}
return name;
};
if (!request.as_funding_required) {
return (
<div>
@ -47,6 +445,23 @@ const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request })
}
}
// Check if we have any invoice files
const hasInvoiceFiles = (request.invoice_files && request.invoice_files.length > 0) || request.invoice;
// Use hardcoded PocketBase URL instead of environment variable
const pocketbaseUrl = "https://pocketbase.ieeeucsd.org";
// Open file preview modal
const openFilePreview = (fileName: string, displayName: string) => {
setSelectedFile({ name: fileName, displayName });
setIsPreviewModalOpen(true);
};
// Close file preview modal
const closeFilePreview = () => {
setIsPreviewModalOpen(false);
};
return (
<div className="space-y-6">
<div>
@ -61,16 +476,130 @@ const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request })
</div>
)}
{/* Display invoice files if available */}
{hasInvoiceFiles ? (
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Invoice Files</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-2">
{/* Display invoice_files array if available */}
{request.invoice_files && request.invoice_files.map((fileId, index) => {
const extension = getFileExtension(fileId);
const displayName = getFriendlyFileName(fileId, 25);
return (
<div
key={`file-${index}`}
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer"
onClick={() => openFilePreview(fileId, displayName)}
>
<div className="p-4 flex items-center gap-3">
{isImageFile(fileId) ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
) : isPdfFile(fileId) ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)}
<div className="flex-grow">
<p className="font-medium truncate" title={fileId}>{displayName}</p>
<p className="text-xs text-gray-400">
{extension ? extension.toUpperCase() : 'FILE'}
</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
</div>
);
})}
{/* Display single invoice file if available */}
{request.invoice && (
<div
key="invoice"
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer"
onClick={() => {
const invoiceFile = request.invoice || '';
openFilePreview(invoiceFile, getFriendlyFileName(invoiceFile, 25));
}}
>
<div className="p-4 flex items-center gap-3">
{isImageFile(request.invoice) ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
) : isPdfFile(request.invoice) ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)}
<div className="flex-grow">
<p className="font-medium truncate" title={request.invoice}>
{getFriendlyFileName(request.invoice, 25)}
</p>
<p className="text-xs text-gray-400">
{getFileExtension(request.invoice) ? getFileExtension(request.invoice).toUpperCase() : 'FILE'}
</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
</div>
)}
</div>
</div>
) : (
<div className="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-6 h-6"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>No invoice files have been uploaded.</span>
</div>
)}
{/* Display invoice data if available */}
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Invoice Data</h4>
<InvoiceTable invoiceData={invoiceData} />
</div>
{/* File Preview Modal */}
<FilePreviewModal
isOpen={isPreviewModalOpen}
onClose={closeFilePreview}
collectionName={Collections.EVENT_REQUESTS}
recordId={request.id}
fileName={selectedFile.name}
displayName={selectedFile.displayName}
/>
</div>
);
};
// Separate component for invoice table
const InvoiceTable: React.FC<{ invoiceData: any }> = ({ invoiceData }) => {
// If no invoice data is provided, show a message
if (!invoiceData) {
return (
<div className="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-6 h-6"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>No invoice data available.</span>
</div>
);
}
try {
// Parse invoice data if it's a string
let parsedInvoice = null;
@ -120,7 +649,7 @@ const InvoiceTable: React.FC<{ invoiceData: any }> = ({ invoiceData }) => {
}
// If we still don't have items, check if the object itself looks like an item
if (items.length === 0 && parsedInvoice.item || parsedInvoice.description || parsedInvoice.name) {
if (items.length === 0 && (parsedInvoice.item || parsedInvoice.description || parsedInvoice.name)) {
items = [parsedInvoice];
}

View file

@ -25,6 +25,7 @@ interface ExtendedEventRequest extends SchemaEventRequest {
[key: string]: any;
};
invoice_data?: any;
invoice_files?: string[]; // Array of invoice file IDs
status: "submitted" | "pending" | "completed" | "declined";
}