1827 lines
No EOL
89 KiB
TypeScript
1827 lines
No EOL
89 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import toast from 'react-hot-toast';
|
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
|
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
|
|
import { Icon } from "@iconify/react";
|
|
import CustomAlert from '../universal/CustomAlert';
|
|
import UniversalFilePreview from '../universal/FilePreview';
|
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
|
|
|
// Extended EventRequest interface with additional properties needed for this component
|
|
interface ExtendedEventRequest extends SchemaEventRequest {
|
|
requested_user_expand?: {
|
|
name: string;
|
|
email: string;
|
|
};
|
|
expand?: {
|
|
requested_user?: {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
emailVisibility?: boolean;
|
|
[key: string]: any;
|
|
};
|
|
[key: string]: any;
|
|
};
|
|
invoice_data?: string | any;
|
|
invoice_files?: string[]; // Array of invoice file IDs
|
|
flyer_files?: string[]; // Add this for PR-related files
|
|
files?: string[]; // Generic files field
|
|
will_or_have_room_booking?: boolean;
|
|
room_booking?: string; // Single file for room booking
|
|
room_reservation_needed?: boolean; // Keep for backward compatibility
|
|
additional_notes?: string;
|
|
}
|
|
|
|
interface EventRequestDetailsProps {
|
|
request: ExtendedEventRequest;
|
|
onClose: () => void;
|
|
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<string>('');
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const loadFile = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
// Construct the secure file URL
|
|
const auth = Authentication.getInstance();
|
|
const token = auth.getAuthToken();
|
|
|
|
// Use hardcoded PocketBase URL
|
|
const secureUrl = `https://pocketbase.ieeeucsd.org/api/files/${collectionName}/${recordId}/${fileName}?token=${token}`;
|
|
|
|
setFileUrl(secureUrl);
|
|
|
|
// Determine file type from extension
|
|
const extension = fileName.split('.').pop()?.toLowerCase() || '';
|
|
setFileType(extension);
|
|
|
|
setIsLoading(false);
|
|
} catch (err) {
|
|
console.error('Error loading file:', err);
|
|
setError('Failed to load file. Please try again.');
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
loadFile();
|
|
}, [isOpen, collectionName, recordId, fileName]);
|
|
|
|
const handleDownload = async () => {
|
|
try {
|
|
// Create a temporary link element
|
|
const link = document.createElement('a');
|
|
link.href = fileUrl;
|
|
link.download = displayName || fileName;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
} catch (err) {
|
|
console.error('Error downloading file:', err);
|
|
setError('Failed to download file. Please try again.');
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-black/70 backdrop-blur-xs z-300 flex items-center justify-center p-4 overflow-y-auto"
|
|
>
|
|
<div className="bg-base-300 rounded-xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-hidden relative">
|
|
<div className="p-4 flex justify-between items-center border-b border-base-200">
|
|
<h3 className="text-lg font-bold truncate">{displayName || fileName}</h3>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleDownload}
|
|
className="btn btn-sm btn-ghost"
|
|
>
|
|
<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
|
|
onClick={onClose}
|
|
className="btn btn-sm btn-ghost"
|
|
>
|
|
<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>
|
|
|
|
<div className="p-4 overflow-auto max-h-[70vh]">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="loading loading-spinner loading-lg"></div>
|
|
</div>
|
|
) : error ? (
|
|
<div className="bg-error/10 text-error p-4 rounded-lg">
|
|
<p>{error}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{(fileType === 'jpg' || fileType === 'jpeg' || fileType === 'png' || fileType === 'gif') ? (
|
|
<div className="flex justify-center">
|
|
<img
|
|
src={fileUrl}
|
|
alt={displayName || fileName}
|
|
className="max-w-full max-h-[60vh] object-contain rounded-lg"
|
|
onError={() => setError('Failed to load image.')}
|
|
/>
|
|
</div>
|
|
) : fileType === 'pdf' ? (
|
|
<iframe
|
|
src={`${fileUrl}#view=FitH`}
|
|
className="w-full h-[60vh] rounded-lg"
|
|
title={displayName || fileName}
|
|
></iframe>
|
|
) : (
|
|
<div className="bg-base-200 p-6 rounded-lg text-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 mx-auto 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>
|
|
<h4 className="text-lg font-semibold mb-2">File Preview Not Available</h4>
|
|
<p className="text-base-content/70 mb-4">
|
|
This file type ({fileType}) cannot be previewed in the browser.
|
|
</p>
|
|
<button
|
|
onClick={handleDownload}
|
|
className="btn btn-primary btn-sm"
|
|
>
|
|
Download File
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.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">
|
|
<Icon className="h-5 w-5 text-warning" icon="heroicons:book-open" />
|
|
<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 (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="bg-base-100/10 p-6 rounded-lg border border-base-100/10"
|
|
>
|
|
<div className="text-center py-8">
|
|
<Icon icon="mdi:cash-off" className="h-16 w-16 mx-auto text-gray-500 mb-4" />
|
|
<h3 className="text-xl font-semibold mb-2">No AS Funding Required</h3>
|
|
<p className="text-gray-400">This event does not require AS funding.</p>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
// Process invoice data for display
|
|
let invoiceData = request.invoice_data;
|
|
|
|
// If invoice_data is not available, try to parse itemized_invoice
|
|
if (!invoiceData && request.itemized_invoice) {
|
|
try {
|
|
if (typeof request.itemized_invoice === 'string') {
|
|
invoiceData = JSON.parse(request.itemized_invoice);
|
|
} else if (typeof request.itemized_invoice === 'object') {
|
|
invoiceData = request.itemized_invoice;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to parse itemized_invoice:', e);
|
|
}
|
|
}
|
|
|
|
// 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 (
|
|
<motion.div
|
|
className="space-y-6"
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
>
|
|
<div className="flex flex-col md:flex-row gap-6">
|
|
{/* Funding Status Card */}
|
|
<motion.div
|
|
className="bg-base-100/10 p-5 rounded-lg border border-base-100/10 flex-1"
|
|
initial={{ opacity: 0, x: -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: 0.1 }}
|
|
>
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className="bg-success/20 p-3 rounded-full">
|
|
<Icon icon="mdi:cash-multiple" className="h-6 w-6 text-success" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold">AS Funding Status</h3>
|
|
<p className="text-sm text-gray-400">Funding has been requested for this event</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
|
<div className="bg-base-200/30 p-3 rounded-lg">
|
|
<span className="text-xs text-gray-400 block mb-1">Funding Status</span>
|
|
<div className="flex items-center gap-2">
|
|
<div className="badge badge-success">Required</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-base-200/30 p-3 rounded-lg">
|
|
<span className="text-xs text-gray-400 block mb-1">Food & Drinks</span>
|
|
<div className="flex items-center gap-2">
|
|
{request.food_drinks_being_served ? (
|
|
<div className="badge badge-success">Yes</div>
|
|
) : (
|
|
<div className="badge badge-ghost">No</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Invoice Summary Card (if available) */}
|
|
{invoiceData && (
|
|
<motion.div
|
|
className="bg-base-100/10 p-5 rounded-lg border border-base-100/10 flex-1"
|
|
initial={{ opacity: 0, x: 10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: 0.2 }}
|
|
>
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className="bg-primary/20 p-3 rounded-full">
|
|
<Icon icon="mdi:file-document-outline" className="h-6 w-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold">Invoice Summary</h3>
|
|
<p className="text-sm text-gray-400">Quick overview of funding details</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
{typeof invoiceData === 'object' && invoiceData !== null && (
|
|
<div className="space-y-2">
|
|
{/* Calculate total from invoiceData */}
|
|
{(() => {
|
|
let total = 0;
|
|
let items = [];
|
|
|
|
if (invoiceData.items && Array.isArray(invoiceData.items)) {
|
|
items = invoiceData.items;
|
|
} else if (Array.isArray(invoiceData)) {
|
|
items = invoiceData;
|
|
}
|
|
|
|
if (items.length > 0) {
|
|
total = items.reduce((sum: number, item: any) => {
|
|
const quantity = parseFloat(item?.quantity || 1);
|
|
const price = parseFloat(item?.unit_price || item?.price || 0);
|
|
return sum + (quantity * price);
|
|
}, 0);
|
|
}
|
|
|
|
// If we have a total in the invoice data, use that instead
|
|
if (invoiceData.total) {
|
|
total = parseFloat(invoiceData.total);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col space-y-3">
|
|
<div className="flex justify-between items-center bg-base-200/30 p-3 rounded-lg">
|
|
<span className="text-sm">Total Amount:</span>
|
|
<span className="text-lg font-bold">${total.toFixed(2)}</span>
|
|
</div>
|
|
|
|
{invoiceData.vendor && (
|
|
<div className="flex justify-between items-center bg-base-200/30 p-3 rounded-lg">
|
|
<span className="text-sm">Vendor:</span>
|
|
<span className="font-medium">{invoiceData.vendor}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-between items-center bg-base-200/30 p-3 rounded-lg">
|
|
<span className="text-sm">Items:</span>
|
|
<span className="font-medium">{items.length} items</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Invoice Files Section */}
|
|
{hasInvoiceFiles ? (
|
|
<motion.div
|
|
className="bg-base-100/10 p-5 rounded-lg border border-base-100/10"
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.3 }}
|
|
>
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className="bg-info/20 p-3 rounded-full">
|
|
<Icon icon="mdi:file-multiple-outline" className="h-6 w-6 text-info" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold">Invoice Files</h3>
|
|
<p className="text-sm text-gray-400">Attached documentation for the funding request</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-4">
|
|
{/* 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 (
|
|
<motion.div
|
|
key={`file-${index}`}
|
|
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer shadow-xs"
|
|
onClick={() => openFilePreview(fileId, displayName)}
|
|
initial={{ opacity: 0, y: 5 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.3 + (index * 0.05) }}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
<div className="p-4 flex items-center gap-3">
|
|
{isImageFile(fileId) ? (
|
|
<Icon icon="mdi:image" className="h-8 w-8 text-primary" />
|
|
) : isPdfFile(fileId) ? (
|
|
<Icon icon="mdi:file-pdf-box" className="h-8 w-8 text-error" />
|
|
) : (
|
|
<Icon icon="mdi:file-document" className="h-8 w-8 text-secondary" />
|
|
)}
|
|
<div className="grow">
|
|
<p className="font-medium truncate" title={fileId}>
|
|
{displayName}
|
|
</p>
|
|
<p className="text-xs text-gray-400">
|
|
{extension ? extension.toUpperCase() : 'FILE'}
|
|
</p>
|
|
</div>
|
|
<Icon icon="mdi:eye" className="h-5 w-5" />
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
|
|
{/* Display single invoice file if available */}
|
|
{request.invoice && (
|
|
<motion.div
|
|
key="invoice"
|
|
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer shadow-xs"
|
|
onClick={() => {
|
|
const invoiceFile = request.invoice || '';
|
|
openFilePreview(invoiceFile, getFriendlyFileName(invoiceFile, 25));
|
|
}}
|
|
initial={{ opacity: 0, y: 5 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.3 + ((request.invoice_files?.length || 0) * 0.05) }}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
<div className="p-4 flex items-center gap-3">
|
|
{isImageFile(request.invoice) ? (
|
|
<Icon icon="mdi:image" className="h-8 w-8 text-primary" />
|
|
) : isPdfFile(request.invoice) ? (
|
|
<Icon icon="mdi:file-pdf-box" className="h-8 w-8 text-error" />
|
|
) : (
|
|
<Icon icon="mdi:file-document" className="h-8 w-8 text-secondary" />
|
|
)}
|
|
<div className="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>
|
|
<Icon icon="mdi:eye" className="h-5 w-5" />
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.3 }}
|
|
>
|
|
<CustomAlert
|
|
type="info"
|
|
title="No Invoice Files"
|
|
message="No invoice files have been uploaded for this funding request."
|
|
icon="heroicons:information-circle"
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Invoice Data Table */}
|
|
<motion.div
|
|
className="bg-base-100/10 p-5 rounded-lg border border-base-100/10"
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.4 }}
|
|
>
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className="bg-secondary/20 p-3 rounded-full">
|
|
<Icon icon="mdi:table-large" className="h-6 w-6 text-secondary" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold">Invoice Details</h3>
|
|
<p className="text-sm text-gray-400">Itemized breakdown of the funding request</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<InvoiceTable invoiceData={invoiceData} expectedAttendance={request.expected_attendance} />
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Copyable Invoice Format */}
|
|
<motion.div
|
|
className="bg-base-100/10 p-5 rounded-lg border border-base-100/10"
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.5 }}
|
|
>
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className="bg-info/20 p-3 rounded-full">
|
|
<Icon icon="mdi:content-copy" className="h-6 w-6 text-info" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold">Copyable Format</h3>
|
|
<p className="text-sm text-gray-400">Copy formatted invoice data for easy sharing</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<CopyableInvoiceFormat invoiceData={invoiceData} />
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* File Preview Modal */}
|
|
<FilePreviewModal
|
|
isOpen={isPreviewModalOpen}
|
|
onClose={closeFilePreview}
|
|
collectionName={Collections.EVENT_REQUESTS}
|
|
recordId={request.id}
|
|
fileName={selectedFile.name}
|
|
displayName={selectedFile.displayName}
|
|
/>
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
// Component for copyable invoice format
|
|
const CopyableInvoiceFormat: React.FC<{ invoiceData: any }> = ({ invoiceData }) => {
|
|
const [copied, setCopied] = useState(false);
|
|
const [formattedText, setFormattedText] = useState<string>('');
|
|
|
|
useEffect(() => {
|
|
if (!invoiceData) {
|
|
setFormattedText('No invoice data available');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Parse invoice data if it's a string
|
|
let parsedInvoice = null;
|
|
|
|
if (typeof invoiceData === 'string') {
|
|
try {
|
|
parsedInvoice = JSON.parse(invoiceData);
|
|
} catch (e) {
|
|
console.error('Failed to parse invoice data string:', e);
|
|
setFormattedText('Invalid invoice data format');
|
|
return;
|
|
}
|
|
} else if (typeof invoiceData === 'object' && invoiceData !== null) {
|
|
parsedInvoice = invoiceData;
|
|
} else {
|
|
setFormattedText('No structured invoice data available');
|
|
return;
|
|
}
|
|
|
|
// Extract items array
|
|
let items = [];
|
|
if (parsedInvoice.items && Array.isArray(parsedInvoice.items)) {
|
|
items = parsedInvoice.items;
|
|
} else if (Array.isArray(parsedInvoice)) {
|
|
items = parsedInvoice;
|
|
} else if (parsedInvoice.items && typeof parsedInvoice.items === 'object') {
|
|
items = [parsedInvoice.items]; // Wrap single item in array
|
|
} else {
|
|
// Try to find any array in the object
|
|
for (const key in parsedInvoice) {
|
|
if (Array.isArray(parsedInvoice[key])) {
|
|
items = parsedInvoice[key];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)) {
|
|
items = [parsedInvoice];
|
|
}
|
|
|
|
// Format the items into the required string format
|
|
const formattedItems = items.map((item: any) => {
|
|
const quantity = parseFloat(item?.quantity || 1);
|
|
const itemName = typeof item?.item === 'object'
|
|
? JSON.stringify(item.item)
|
|
: (item?.item || item?.description || item?.name || 'N/A');
|
|
const unitPrice = parseFloat(item?.unit_price || item?.unitPrice || item?.price || 0);
|
|
|
|
return `${quantity} ${itemName} x${unitPrice.toFixed(2)} each`;
|
|
}).join(' | ');
|
|
|
|
// Get tax, tip and total
|
|
const tax = parseFloat(parsedInvoice.tax || parsedInvoice.taxAmount || 0);
|
|
const tip = parseFloat(parsedInvoice.tip || parsedInvoice.tipAmount || 0);
|
|
const total = parseFloat(parsedInvoice.total || 0) ||
|
|
items.reduce((sum: number, item: any) => {
|
|
const quantity = parseFloat(item?.quantity || 1);
|
|
const price = parseFloat(item?.unit_price || item?.unitPrice || item?.price || 0);
|
|
return sum + (quantity * price);
|
|
}, 0) + tax + tip;
|
|
|
|
// Get vendor/location
|
|
const location = parsedInvoice.vendor || parsedInvoice.location || 'Unknown Vendor';
|
|
|
|
// Build the final formatted string
|
|
let result = formattedItems;
|
|
|
|
if (tax > 0) {
|
|
result += ` | Tax = ${tax.toFixed(2)}`;
|
|
}
|
|
|
|
if (tip > 0) {
|
|
result += ` | Tip = ${tip.toFixed(2)}`;
|
|
}
|
|
|
|
result += ` | Total = ${total.toFixed(2)} from ${location}`;
|
|
|
|
setFormattedText(result);
|
|
} catch (error) {
|
|
console.error('Error formatting invoice data:', error);
|
|
setFormattedText('Error formatting invoice data');
|
|
}
|
|
}, [invoiceData]);
|
|
|
|
const copyToClipboard = () => {
|
|
navigator.clipboard.writeText(formattedText)
|
|
.then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
toast.success('Copied to clipboard!');
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to copy text: ', err);
|
|
toast.error('Failed to copy text');
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="bg-base-200/30 p-4 rounded-lg">
|
|
<div className="flex justify-between items-start mb-2">
|
|
<label className="text-sm font-medium text-gray-400">Formatted Invoice Data</label>
|
|
<button
|
|
onClick={copyToClipboard}
|
|
className="btn btn-sm btn-primary gap-2"
|
|
disabled={!formattedText || formattedText.includes('No') || formattedText.includes('Error')}
|
|
>
|
|
{copied ? (
|
|
<>
|
|
<Icon icon="mdi:check" className="h-4 w-4" />
|
|
Copied!
|
|
</>
|
|
) : (
|
|
<>
|
|
<Icon icon="mdi:content-copy" className="h-4 w-4" />
|
|
Copy
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
<div className="bg-base-300/50 p-3 rounded-lg mt-2 whitespace-pre-wrap break-words text-sm">
|
|
{formattedText}
|
|
</div>
|
|
<p className="text-xs text-gray-400 mt-2">
|
|
Format: N_1 {'{item_1}'} x{'{cost_1}'} each | N_2 {'{item_2}'} x{'{cost_2}'} each | Tax = {'{tax}'} | Tip = {'{tip}'} | Total = {'{total}'} from {'{location}'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Separate component for invoice table
|
|
const InvoiceTable: React.FC<{ invoiceData: any, expectedAttendance?: number }> = ({ invoiceData, expectedAttendance }) => {
|
|
// If no invoice data is provided, show a message
|
|
if (!invoiceData) {
|
|
return (
|
|
<CustomAlert
|
|
type="info"
|
|
title="No Invoice Data"
|
|
message="No invoice data available."
|
|
icon="heroicons:information-circle"
|
|
/>
|
|
);
|
|
}
|
|
|
|
try {
|
|
// Parse invoice data if it's a string
|
|
let parsedInvoice = null;
|
|
|
|
if (typeof invoiceData === 'string') {
|
|
try {
|
|
parsedInvoice = JSON.parse(invoiceData);
|
|
} catch (e) {
|
|
console.error('Failed to parse invoice data string:', e);
|
|
return (
|
|
<CustomAlert
|
|
type="warning"
|
|
title="Invalid Format"
|
|
message="Invalid invoice data format."
|
|
icon="heroicons:exclamation-triangle"
|
|
/>
|
|
);
|
|
}
|
|
} else if (typeof invoiceData === 'object' && invoiceData !== null) {
|
|
parsedInvoice = invoiceData;
|
|
}
|
|
|
|
// Check if we have valid invoice data
|
|
if (!parsedInvoice || typeof parsedInvoice !== 'object') {
|
|
return (
|
|
<CustomAlert
|
|
type="info"
|
|
title="No Structured Data"
|
|
message="No structured invoice data available."
|
|
icon="heroicons:information-circle"
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Extract items array
|
|
let items = [];
|
|
if (parsedInvoice.items && Array.isArray(parsedInvoice.items)) {
|
|
items = parsedInvoice.items;
|
|
} else if (Array.isArray(parsedInvoice)) {
|
|
items = parsedInvoice;
|
|
} else if (parsedInvoice.items && typeof parsedInvoice.items === 'object') {
|
|
items = [parsedInvoice.items]; // Wrap single item in array
|
|
} else {
|
|
// Try to find any array in the object
|
|
for (const key in parsedInvoice) {
|
|
if (Array.isArray(parsedInvoice[key])) {
|
|
items = parsedInvoice[key];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)) {
|
|
items = [parsedInvoice];
|
|
}
|
|
|
|
// If we still don't have items, show a message
|
|
if (items.length === 0) {
|
|
return (
|
|
<CustomAlert
|
|
type="info"
|
|
title="No Invoice Items"
|
|
message="No invoice items found in the data."
|
|
icon="heroicons:information-circle"
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Calculate subtotal from items
|
|
const subtotal = items.reduce((sum: number, item: any) => {
|
|
const quantity = parseFloat(item?.quantity || 1);
|
|
const price = parseFloat(item?.unit_price || item?.unitPrice || item?.price || 0);
|
|
return sum + (quantity * price);
|
|
}, 0);
|
|
|
|
// Get tax, tip and total
|
|
const tax = parseFloat(parsedInvoice.tax || parsedInvoice.taxAmount || 0);
|
|
const tip = parseFloat(parsedInvoice.tip || parsedInvoice.tipAmount || 0);
|
|
const total = parseFloat(parsedInvoice.total || 0) || (subtotal + tax + tip);
|
|
|
|
// Calculate budget limit if expected attendance is provided
|
|
const budgetLimit = expectedAttendance ? Math.min(expectedAttendance * 10, 5000) : null;
|
|
const isOverBudget = budgetLimit !== null && total > budgetLimit;
|
|
|
|
// Render the invoice table
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
{budgetLimit !== null && (
|
|
<div className={`mb-4 p-3 rounded-lg ${isOverBudget ? 'bg-error/20' : 'bg-success/20'}`}>
|
|
<p className="text-sm font-medium">
|
|
Budget Limit: ${budgetLimit.toFixed(2)} (based on {expectedAttendance} attendees)
|
|
</p>
|
|
{isOverBudget && (
|
|
<p className="text-sm font-bold text-error mt-1">
|
|
WARNING: This invoice exceeds the budget limit by ${(total - budgetLimit).toFixed(2)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<table className="table table-zebra w-full">
|
|
<thead>
|
|
<tr>
|
|
<th>Item</th>
|
|
<th>Quantity</th>
|
|
<th>Price</th>
|
|
<th>Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((item: any, index: number) => {
|
|
// Ensure we're not trying to render an object directly
|
|
const itemName = typeof item?.item === 'object'
|
|
? JSON.stringify(item.item)
|
|
: (item?.item || item?.description || item?.name || 'N/A');
|
|
|
|
const quantity = parseFloat(item?.quantity || 1);
|
|
const unitPrice = parseFloat(item?.unit_price || item?.unitPrice || item?.price || 0);
|
|
const itemTotal = quantity * unitPrice;
|
|
|
|
return (
|
|
<tr key={index}>
|
|
<td>{itemName}</td>
|
|
<td>{quantity}</td>
|
|
<td>${unitPrice.toFixed(2)}</td>
|
|
<td>${itemTotal.toFixed(2)}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr>
|
|
<td colSpan={3} className="text-right font-medium">Subtotal:</td>
|
|
<td>${subtotal.toFixed(2)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td colSpan={3} className="text-right font-medium">Tax:</td>
|
|
<td>${tax.toFixed(2)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td colSpan={3} className="text-right font-medium">Tip:</td>
|
|
<td>${tip.toFixed(2)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td colSpan={3} className="text-right font-bold">Total:</td>
|
|
<td className={`font-bold ${isOverBudget ? 'text-error' : ''}`}>${total.toFixed(2)}</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
{parsedInvoice.vendor && (
|
|
<div className="mt-3">
|
|
<span className="font-medium">Vendor:</span> {parsedInvoice.vendor}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} catch (error) {
|
|
console.error('Error rendering invoice table:', error);
|
|
return (
|
|
<CustomAlert
|
|
type="error"
|
|
title="Error"
|
|
message="An error occurred while processing the invoice data."
|
|
icon="heroicons:exclamation-circle"
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
// Now, add a new component for the PR Materials tab
|
|
const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }) => {
|
|
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState<boolean>(false);
|
|
const [selectedFile, setSelectedFile] = useState<{ name: string, displayName: string }>({ name: '', displayName: '' });
|
|
|
|
// Format date for display
|
|
const formatDate = (dateString: string) => {
|
|
if (!dateString) return 'Not specified';
|
|
try {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
} catch (e) {
|
|
return dateString;
|
|
}
|
|
};
|
|
|
|
// Use the same utility functions as in the ASFundingTab
|
|
const getFileExtension = (filename: string): string => {
|
|
const parts = filename.split('.');
|
|
return parts.length > 1 ? parts.pop()?.toLowerCase() || '' : '';
|
|
};
|
|
|
|
const isImageFile = (filename: string): boolean => {
|
|
const extension = getFileExtension(filename);
|
|
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(extension);
|
|
};
|
|
|
|
const isPdfFile = (filename: string): boolean => {
|
|
return getFileExtension(filename) === 'pdf';
|
|
};
|
|
|
|
const getFriendlyFileName = (filename: string, maxLength: number = 20): string => {
|
|
const basename = filename.split('/').pop() || filename;
|
|
if (basename.length <= maxLength) return basename;
|
|
const extension = getFileExtension(basename);
|
|
const name = basename.substring(0, basename.length - extension.length - 1);
|
|
const truncatedName = name.substring(0, maxLength - 3 - extension.length) + '...';
|
|
return extension ? `${truncatedName}.${extension}` : truncatedName;
|
|
};
|
|
|
|
// Check if we have any PR-related files (flyer_files or related files)
|
|
const hasFiles = (
|
|
request.flyer_files && request.flyer_files.length > 0 ||
|
|
request.files && request.files.length > 0 ||
|
|
request.other_logos && request.other_logos.length > 0
|
|
);
|
|
|
|
// 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 (
|
|
<motion.div
|
|
className="space-y-6"
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="space-y-4">
|
|
<div className="bg-base-300/20 p-4 rounded-lg">
|
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Flyers Needed</h4>
|
|
<div className="flex items-center gap-2">
|
|
{request.flyers_needed ? (
|
|
<span className="badge badge-success">Yes</span>
|
|
) : (
|
|
<span className="badge badge-ghost">No</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{request.flyers_needed && (
|
|
<motion.div
|
|
className="space-y-4"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.1 }}
|
|
>
|
|
<div className="bg-base-300/20 p-4 rounded-lg">
|
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Flyer Types</h4>
|
|
<ul className="space-y-1 mt-2">
|
|
{request.flyer_type?.map((type, index) => (
|
|
<motion.li
|
|
key={index}
|
|
className="flex items-center gap-2"
|
|
initial={{ opacity: 0, x: -5 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: 0.1 + (index * 0.05) }}
|
|
>
|
|
<Icon icon="mdi:check" className="h-4 w-4 text-success" />
|
|
<span>{type}</span>
|
|
</motion.li>
|
|
))}
|
|
{request.other_flyer_type && (
|
|
<motion.li
|
|
className="flex items-center gap-2"
|
|
initial={{ opacity: 0, x: -5 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: 0.1 + ((request.flyer_type?.length || 0) * 0.05) }}
|
|
>
|
|
<Icon icon="mdi:check" className="h-4 w-4 text-success" />
|
|
<span>{request.other_flyer_type}</span>
|
|
</motion.li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="bg-base-300/20 p-4 rounded-lg">
|
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Advertising Start Date</h4>
|
|
<p>{formatDate(request.flyer_advertising_start_date || '')}</p>
|
|
</div>
|
|
|
|
<div className="bg-base-300/20 p-4 rounded-lg">
|
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Advertising Format</h4>
|
|
<p>{request.advertising_format || 'Not specified'}</p>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="bg-base-300/20 p-4 rounded-lg">
|
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Photography Needed</h4>
|
|
<div className="flex items-center gap-2">
|
|
{request.photography_needed ? (
|
|
<span className="badge badge-success">Yes</span>
|
|
) : (
|
|
<span className="badge badge-ghost">No</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Logo Requirements Section */}
|
|
<motion.div
|
|
className="bg-base-300/20 p-4 rounded-lg"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.1 }}
|
|
>
|
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Required Logos</h4>
|
|
<ul className="space-y-1 mt-2">
|
|
{request.required_logos?.map((logo, index) => (
|
|
<motion.li
|
|
key={index}
|
|
className="flex items-center gap-2"
|
|
initial={{ opacity: 0, x: -5 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: 0.1 + (index * 0.05) }}
|
|
>
|
|
<Icon icon="mdi:check" className="h-4 w-4 text-success" />
|
|
<span>{logo}</span>
|
|
</motion.li>
|
|
))}
|
|
{(!request.required_logos || request.required_logos.length === 0) && (
|
|
<motion.li
|
|
className="flex items-center gap-2"
|
|
initial={{ opacity: 0, x: -5 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
>
|
|
<Icon icon="mdi:information" className="h-4 w-4 text-info" />
|
|
<span>No specific logos required</span>
|
|
</motion.li>
|
|
)}
|
|
</ul>
|
|
</motion.div>
|
|
|
|
{/* Display custom logos if available */}
|
|
{request.other_logos && request.other_logos.length > 0 && (
|
|
<motion.div
|
|
className="bg-base-300/20 p-4 rounded-lg"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.2 }}
|
|
>
|
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Custom Logos</h4>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mt-2">
|
|
{request.other_logos.map((logoId, index) => {
|
|
const displayName = getFriendlyFileName(logoId, 15);
|
|
|
|
return (
|
|
<motion.div
|
|
key={`logo-${index}`}
|
|
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer shadow-xs"
|
|
onClick={() => openFilePreview(logoId, displayName)}
|
|
initial={{ opacity: 0, y: 5 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.2 + (index * 0.05) }}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
<div className="p-3 flex items-center gap-2">
|
|
{isImageFile(logoId) ? (
|
|
<Icon icon="mdi:image" className="h-5 w-5 text-primary" />
|
|
) : (
|
|
<Icon icon="mdi:file-document" className="h-5 w-5 text-secondary" />
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm truncate" title={logoId}>
|
|
{displayName}
|
|
</p>
|
|
</div>
|
|
<Icon icon="mdi:eye" className="h-4 w-4" />
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
<div className="bg-base-300/20 p-4 rounded-lg">
|
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Additional Requests</h4>
|
|
<p className="whitespace-pre-line">{request.flyer_additional_requests || 'None'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Display PR-related files if available */}
|
|
{hasFiles && (
|
|
<motion.div
|
|
className="mt-6"
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.2 }}
|
|
>
|
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Related Files</h4>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-2">
|
|
{/* Display flyer_files if available */}
|
|
{request.flyer_files && request.flyer_files.map((fileId, index) => {
|
|
const extension = getFileExtension(fileId);
|
|
const displayName = getFriendlyFileName(fileId, 25);
|
|
|
|
return (
|
|
<motion.div
|
|
key={`flyer-file-${index}`}
|
|
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer shadow-xs"
|
|
onClick={() => openFilePreview(fileId, displayName)}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.2 + (index * 0.05) }}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
<div className="p-4 flex items-center gap-3">
|
|
{isImageFile(fileId) ? (
|
|
<Icon icon="mdi:image" className="h-8 w-8 text-primary" />
|
|
) : isPdfFile(fileId) ? (
|
|
<Icon icon="mdi:file-pdf-box" className="h-8 w-8 text-error" />
|
|
) : (
|
|
<Icon icon="mdi:file-document" className="h-8 w-8 text-secondary" />
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate" title={fileId}>
|
|
{displayName}
|
|
</p>
|
|
<p className="text-xs text-gray-400">
|
|
{extension ? extension.toUpperCase() : 'FILE'}
|
|
</p>
|
|
</div>
|
|
<Icon icon="mdi:eye" className="h-5 w-5" />
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
|
|
{/* Display general files if available */}
|
|
{request.files && request.files.map((fileId, index) => {
|
|
const extension = getFileExtension(fileId);
|
|
const displayName = getFriendlyFileName(fileId, 25);
|
|
|
|
return (
|
|
<motion.div
|
|
key={`general-file-${index}`}
|
|
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer shadow-xs"
|
|
onClick={() => openFilePreview(fileId, displayName)}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.2 + ((request.flyer_files?.length || 0) + index) * 0.05 }}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
<div className="p-4 flex items-center gap-3">
|
|
{isImageFile(fileId) ? (
|
|
<Icon icon="mdi:image" className="h-8 w-8 text-primary" />
|
|
) : isPdfFile(fileId) ? (
|
|
<Icon icon="mdi:file-pdf-box" className="h-8 w-8 text-error" />
|
|
) : (
|
|
<Icon icon="mdi:file-document" className="h-8 w-8 text-secondary" />
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate" title={fileId}>
|
|
{displayName}
|
|
</p>
|
|
<p className="text-xs text-gray-400">
|
|
{extension ? extension.toUpperCase() : 'FILE'}
|
|
</p>
|
|
</div>
|
|
<Icon icon="mdi:eye" className="h-5 w-5" />
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* No files message */}
|
|
{
|
|
!hasFiles && (
|
|
<motion.div
|
|
className="mt-4"
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.2 }}
|
|
>
|
|
<CustomAlert
|
|
type="info"
|
|
title="No PR Files"
|
|
message="No PR-related files have been uploaded."
|
|
icon="heroicons:information-circle"
|
|
/>
|
|
</motion.div>
|
|
)
|
|
}
|
|
|
|
{/* File Preview Modal */}
|
|
<FilePreviewModal
|
|
isOpen={isPreviewModalOpen}
|
|
onClose={closeFilePreview}
|
|
collectionName={Collections.EVENT_REQUESTS}
|
|
recordId={request.id}
|
|
fileName={selectedFile.name}
|
|
displayName={selectedFile.displayName}
|
|
/>
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
// Now, update the EventRequestDetails component to use the new PRMaterialsTab
|
|
const EventRequestDetails = ({
|
|
request,
|
|
onClose,
|
|
onStatusChange
|
|
}: EventRequestDetailsProps): React.ReactNode => {
|
|
const [activeTab, setActiveTab] = useState('details');
|
|
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
|
const [newStatus, setNewStatus] = useState<"submitted" | "pending" | "completed" | "declined">("pending");
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [alertInfo, setAlertInfo] = useState<{ show: boolean; type: "success" | "error" | "warning" | "info"; message: string }>({
|
|
show: false,
|
|
type: "info",
|
|
message: ""
|
|
});
|
|
|
|
const formatDate = (dateString: string) => {
|
|
if (!dateString) return "Not specified";
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' });
|
|
};
|
|
|
|
const getStatusBadge = (status?: "submitted" | "pending" | "completed" | "declined") => {
|
|
if (!status) return 'badge-warning';
|
|
|
|
switch (status) {
|
|
case 'completed':
|
|
return 'badge-success';
|
|
case 'declined':
|
|
return 'badge-error';
|
|
case 'pending':
|
|
return 'badge-warning';
|
|
case 'submitted':
|
|
return 'badge-info';
|
|
default:
|
|
return 'badge-warning';
|
|
}
|
|
};
|
|
|
|
const handleStatusChange = async (newStatus: "submitted" | "pending" | "completed" | "declined") => {
|
|
setNewStatus(newStatus);
|
|
setIsConfirmModalOpen(true);
|
|
};
|
|
|
|
const confirmStatusChange = async () => {
|
|
setIsSubmitting(true);
|
|
setAlertInfo({ show: false, type: "info", message: "" });
|
|
|
|
try {
|
|
await onStatusChange(request.id, newStatus);
|
|
setAlertInfo({
|
|
show: true,
|
|
type: "success",
|
|
message: `Status successfully changed to ${newStatus}.`
|
|
});
|
|
} catch (error) {
|
|
setAlertInfo({
|
|
show: true,
|
|
type: "error",
|
|
message: `Failed to update status: ${error}`
|
|
});
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
setIsConfirmModalOpen(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-transparent w-full">
|
|
{/* Tabs navigation */}
|
|
<div className="px-6 pt-2 mb-4">
|
|
<div className="flex flex-wrap gap-2 border-b border-base-100/20">
|
|
<button
|
|
className={`px-4 py-2 font-medium transition-all rounded-t-lg ${activeTab === 'details' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-base-100/10 text-gray-300'}`}
|
|
onClick={() => setActiveTab('details')}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Icon icon="mdi:information-outline" className="h-4 w-4" />
|
|
Event Details
|
|
</div>
|
|
</button>
|
|
{request.as_funding_required && (
|
|
<button
|
|
className={`px-4 py-2 font-medium transition-all rounded-t-lg ${activeTab === 'funding' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-base-100/10 text-gray-300'}`}
|
|
onClick={() => setActiveTab('funding')}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Icon icon="mdi:cash-multiple" className="h-4 w-4" />
|
|
AS Funding
|
|
</div>
|
|
</button>
|
|
)}
|
|
{request.flyers_needed && (
|
|
<button
|
|
className={`px-4 py-2 font-medium transition-all rounded-t-lg ${activeTab === 'pr' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-base-100/10 text-gray-300'}`}
|
|
onClick={() => setActiveTab('pr')}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Icon icon="mdi:image-outline" className="h-4 w-4" />
|
|
PR Materials
|
|
</div>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Alert for status updates */}
|
|
{alertInfo.show && (
|
|
<div className="px-6 mb-4">
|
|
<CustomAlert
|
|
type={alertInfo.type}
|
|
title={alertInfo.type.charAt(0).toUpperCase() + alertInfo.type.slice(1)}
|
|
message={alertInfo.message}
|
|
onClose={() => setAlertInfo({ ...alertInfo, show: false })}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Status bar */}
|
|
<div className="mb-6 px-6">
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 p-4 bg-base-100/10 rounded-lg border border-base-100/10">
|
|
<div className="space-y-1">
|
|
<div className="text-sm text-gray-400">
|
|
Requested by: <span className="text-white">
|
|
{request.requested_user_expand?.name ||
|
|
(request.expand?.requested_user?.name) ||
|
|
'Unknown'}
|
|
</span>
|
|
{" - "}
|
|
<span className="text-white">
|
|
{request.requested_user_expand?.email ||
|
|
(request.expand?.requested_user?.email) ||
|
|
'No email available'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`badge ${getStatusBadge(request.status)}`}>
|
|
{request.status?.charAt(0).toUpperCase() + request.status?.slice(1) || 'Pending'}
|
|
</span>
|
|
<span className="text-xs text-gray-400">
|
|
Submitted on {formatDate(request.created)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="dropdown dropdown-end">
|
|
<label tabIndex={0} className="btn btn-sm">
|
|
Update Status
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</label>
|
|
<ul tabIndex={0} className="dropdown-content z-101 menu p-2 shadow-sm bg-base-200 rounded-lg w-52">
|
|
<li>
|
|
<button
|
|
className={`flex items-center ${request.status === 'pending' ? 'bg-warning/20 text-warning' : ''}`}
|
|
onClick={() => handleStatusChange('pending')}
|
|
disabled={request.status === 'pending'}
|
|
>
|
|
<div className="w-2 h-2 rounded-full bg-warning mr-2"></div>
|
|
Pending
|
|
</button>
|
|
</li>
|
|
<li>
|
|
<button
|
|
className={`flex items-center ${request.status === 'completed' ? 'bg-success/20 text-success' : ''}`}
|
|
onClick={() => handleStatusChange('completed')}
|
|
disabled={request.status === 'completed'}
|
|
>
|
|
<div className="w-2 h-2 rounded-full bg-success mr-2"></div>
|
|
Completed
|
|
</button>
|
|
</li>
|
|
<li>
|
|
<button
|
|
className={`flex items-center ${request.status === 'declined' ? 'bg-error/20 text-error' : ''}`}
|
|
onClick={() => handleStatusChange('declined')}
|
|
disabled={request.status === 'declined'}
|
|
>
|
|
<div className="w-2 h-2 rounded-full bg-error mr-2"></div>
|
|
Declined
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
<div className="px-6 pb-6">
|
|
{activeTab === 'details' && (
|
|
<div className="space-y-6">
|
|
<div className="bg-base-100/10 p-5 rounded-lg border border-base-100/10">
|
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
|
<Icon icon="mdi:information-outline" className="h-5 w-5 mr-2 text-primary" />
|
|
Event Information
|
|
</h3>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
|
<div>
|
|
<label className="text-xs text-gray-400">Event Name</label>
|
|
<p className="text-white font-medium">{request.name}</p>
|
|
</div>
|
|
|
|
<div className="md:row-span-2">
|
|
<label className="text-xs text-gray-400">Event Description</label>
|
|
<p className="text-white">{request.event_description}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs text-gray-400">Location</label>
|
|
<p className="text-white">{request.location}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs text-gray-400">Start Date & Time</label>
|
|
<p className="text-white">{formatDate(request.start_date_time)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="bg-base-100/10 p-5 rounded-lg border border-base-100/10">
|
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
|
<Icon icon="mdi:check-decagram-outline" className="h-5 w-5 mr-2 text-primary" />
|
|
Requirements & Special Requests
|
|
</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between bg-base-200/30 p-3 rounded-lg">
|
|
<p className="text-white">AS Funding Required</p>
|
|
<div className={`badge ${request.as_funding_required ? 'badge-success' : 'badge-ghost'}`}>
|
|
{request.as_funding_required ? 'Yes' : 'No'}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between bg-base-200/30 p-3 rounded-lg">
|
|
<p className="text-white">PR Materials Needed</p>
|
|
<div className={`badge ${request.flyers_needed ? 'badge-success' : 'badge-ghost'}`}>
|
|
{request.flyers_needed ? 'Yes' : 'No'}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between bg-base-200/30 p-3 rounded-lg">
|
|
<p className="text-white">Room Reservation Needed</p>
|
|
<div className={`badge ${request.will_or_have_room_booking ? 'badge-success' : 'badge-ghost'}`}>
|
|
{request.will_or_have_room_booking ? 'Yes' : 'No'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{request.will_or_have_room_booking ? (
|
|
<div className="bg-base-100/10 p-5 rounded-lg border border-base-100/10">
|
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
|
<Icon icon="mdi:map-marker-outline" className="h-5 w-5 mr-2 text-primary" />
|
|
Room Reservation Details
|
|
</h3>
|
|
<div className="space-y-3">
|
|
<div className="bg-base-200/30 p-3 rounded-lg">
|
|
<label className="text-xs text-gray-400 block mb-1">Room/Location</label>
|
|
<p className="text-white font-medium">{request.location || 'Not specified'}</p>
|
|
</div>
|
|
<div className="bg-base-200/30 p-3 rounded-lg">
|
|
<label className="text-xs text-gray-400 block mb-1">Confirmation Status</label>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`badge ${request.room_booking ? 'badge-success' : 'badge-warning'}`}>
|
|
{request.room_booking ? 'Booking File Uploaded' : 'No Booking File'}
|
|
</div>
|
|
{request.room_booking && (
|
|
<button
|
|
onClick={() => {
|
|
// Dispatch event to update file preview modal
|
|
const event = new CustomEvent('filePreviewStateChange', {
|
|
detail: {
|
|
url: `https://pocketbase.ieeeucsd.org/api/files/event_request/${request.id}/${request.room_booking}`,
|
|
filename: request.room_booking
|
|
}
|
|
});
|
|
window.dispatchEvent(event);
|
|
|
|
// Open the modal
|
|
const modal = document.getElementById('file-preview-modal') as HTMLDialogElement;
|
|
if (modal) modal.showModal();
|
|
}}
|
|
className="btn btn-xs btn-primary ml-2"
|
|
>
|
|
View File
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
request.files && request.files.length > 0 && (
|
|
<div className="bg-base-100/10 p-5 rounded-lg border border-base-100/10">
|
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
|
<Icon icon="mdi:file-document-outline" className="h-5 w-5 mr-2 text-primary" />
|
|
Event Files
|
|
</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{request.files.map((file, index) => (
|
|
<FilePreview
|
|
key={index}
|
|
fileUrl={`https://pocketbase.ieeeucsd.org/api/files/event_requests/${request.id}/${file}`}
|
|
fileName={file}
|
|
collectionName="event_requests"
|
|
recordId={request.id}
|
|
originalFileName={file}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
|
|
{/* Display event files if available and not shown above */}
|
|
{!request.room_reservation_needed && request.files && request.files.length > 0 && (
|
|
<div className="bg-base-100/10 p-5 rounded-lg border border-base-100/10">
|
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
|
<Icon icon="mdi:file-document-outline" className="h-5 w-5 mr-2 text-primary" />
|
|
Event Files
|
|
</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
|
|
{request.files.map((file, index) => (
|
|
<FilePreview
|
|
key={index}
|
|
fileUrl={`https://pocketbase.ieeeucsd.org/api/files/event_requests/${request.id}/${file}`}
|
|
fileName={file}
|
|
collectionName="event_requests"
|
|
recordId={request.id}
|
|
originalFileName={file}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'funding' && request.as_funding_required && <ASFundingTab request={request} />}
|
|
{activeTab === 'pr' && request.flyers_needed && <PRMaterialsTab request={request} />}
|
|
</div>
|
|
|
|
{/* Confirmation modal */}
|
|
{isConfirmModalOpen && (
|
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-xs z-300 flex items-center justify-center p-4">
|
|
<div className="bg-base-300 rounded-lg p-6 w-full max-w-md">
|
|
<h3 className="text-lg font-bold mb-4">Confirm Status Change</h3>
|
|
<p className="mb-4">
|
|
Are you sure you want to change the status to <span className="font-bold text-primary">{newStatus}</span>?
|
|
</p>
|
|
<div className="flex justify-end gap-2 mt-4">
|
|
<button
|
|
className="btn btn-ghost"
|
|
onClick={() => setIsConfirmModalOpen(false)}
|
|
disabled={isSubmitting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={confirmStatusChange}
|
|
disabled={isSubmitting}
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<span className="loading loading-spinner loading-xs"></span>
|
|
Updating...
|
|
</>
|
|
) : (
|
|
'Confirm'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* File Preview Modal */}
|
|
<dialog id="file-preview-modal" className="modal modal-bottom sm:modal-middle">
|
|
<div className="modal-box bg-base-200 p-0 overflow-hidden max-w-4xl">
|
|
<div className="p-4">
|
|
<UniversalFilePreview isModal={true} />
|
|
</div>
|
|
<div className="modal-action mt-0 p-4 border-t border-base-300">
|
|
<form method="dialog">
|
|
<button className="btn btn-sm">Close</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<form method="dialog" className="modal-backdrop">
|
|
<button>close</button>
|
|
</form>
|
|
</dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default EventRequestDetails; |