ieeeucsd-org/src/components/dashboard/Officer_EventRequestForm/ASFundingSection.tsx
2025-05-30 23:00:56 -07:00

479 lines
No EOL
19 KiB
TypeScript

import React, { useState } from 'react';
import { motion } from 'framer-motion';
import toast from 'react-hot-toast';
import type { EventRequestFormData } from './EventRequestForm';
import InvoiceBuilder from './InvoiceBuilder';
import type { InvoiceData, InvoiceItem } from './InvoiceBuilder';
import CustomAlert from '../universal/CustomAlert';
import { Icon } from '@iconify/react';
// Enhanced animation variants with faster transitions
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.035,
when: "beforeChildren",
duration: 0.3,
ease: "easeOut"
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 10 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 500,
damping: 25,
mass: 0.8,
duration: 0.25
}
}
};
// Input field hover animation
const inputHoverVariants = {
hover: {
scale: 1.01,
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
transition: { duration: 0.15 }
}
};
// Button animation
const buttonVariants = {
hover: {
scale: 1.03,
transition: { duration: 0.15, ease: "easeOut" }
},
tap: {
scale: 0.97,
transition: { duration: 0.1 }
}
};
// Toggle animation
const toggleVariants = {
checked: { backgroundColor: "rgba(var(--p), 0.2)" },
unchecked: { backgroundColor: "rgba(0, 0, 0, 0.05)" },
hover: { scale: 1.01, transition: { duration: 0.15 } }
};
interface ASFundingSectionProps {
formData: EventRequestFormData;
onDataChange: (data: Partial<EventRequestFormData>) => void;
}
const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataChange }) => {
// Check initial budget status
React.useEffect(() => {
if (formData.invoiceData?.total) {
checkBudgetLimit(formData.invoiceData.total);
}
}, [formData.expected_attendance]);
const [invoiceFiles, setInvoiceFiles] = useState<File[]>(formData.invoice_files || []);
const [jsonInput, setJsonInput] = useState<string>('');
const [jsonError, setJsonError] = useState<string>('');
const [showJsonInput, setShowJsonInput] = useState<boolean>(false);
const [isDragging, setIsDragging] = useState(false);
// Handle invoice file upload
const handleInvoiceFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const newFiles = Array.from(e.target.files) as File[];
// Combine existing files with new files instead of replacing
const combinedFiles = [...invoiceFiles, ...newFiles];
setInvoiceFiles(combinedFiles);
onDataChange({ invoice_files: combinedFiles });
}
};
// Handle removing individual files
const handleRemoveFile = (indexToRemove: number) => {
const updatedFiles = invoiceFiles.filter((_, index) => index !== indexToRemove);
setInvoiceFiles(updatedFiles);
onDataChange({ invoice_files: updatedFiles });
};
// Handle clearing all files
const handleClearAllFiles = () => {
setInvoiceFiles([]);
onDataChange({ invoice_files: [] });
};
// Handle JSON input change
const handleJsonInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setJsonInput(e.target.value);
setJsonError('');
};
// Show JSON example
const showJsonExample = () => {
const example = {
vendor: "Example Restaurant",
items: [
{
id: "item-1",
description: "Burger",
quantity: 2,
unitPrice: 12.99,
amount: 25.98
},
{
id: "item-2",
description: "Fries",
quantity: 2,
unitPrice: 4.99,
amount: 9.98
}
],
subtotal: 35.96,
taxRate: 9.0,
taxAmount: 3.24,
tipPercentage: 13.9,
tipAmount: 5.00,
total: 44.20
};
setJsonInput(JSON.stringify(example, null, 2));
};
// Validate and apply JSON
// Check budget limits and show warning if exceeded
const checkBudgetLimit = (total: number) => {
const maxBudget = Math.min(formData.expected_attendance * 10, 5000);
if (total > maxBudget) {
toast.error(`Total amount ($${total.toFixed(2)}) exceeds maximum funding of $${maxBudget.toFixed(2)} for ${formData.expected_attendance} attendees.`, {
duration: 4000,
position: 'top-center'
});
return true;
}
return false;
};
const validateAndApplyJson = () => {
try {
if (!jsonInput.trim()) {
setJsonError('JSON input is empty');
return;
}
const data = JSON.parse(jsonInput);
// Validate structure
if (!data.vendor) {
setJsonError('Vendor field is required');
return;
}
if (!Array.isArray(data.items) || data.items.length === 0) {
setJsonError('Items array is required and must contain at least one item');
return;
}
// Validate items
for (const item of data.items) {
if (!item.description || typeof item.unitPrice !== 'number' || typeof item.quantity !== 'number') {
setJsonError('Each item must have description, unitPrice, and quantity fields');
return;
}
}
// Validate tax, tip, and total
if (typeof data.taxAmount !== 'number') {
setJsonError('Tax amount must be a number');
return;
}
if (typeof data.tipAmount !== 'number') {
setJsonError('Tip amount must be a number');
return;
}
if (typeof data.total !== 'number') {
setJsonError('Total is required and must be a number');
return;
}
// Create itemized invoice string for Pocketbase
const itemizedInvoice = JSON.stringify({
vendor: data.vendor,
items: data.items.map((item: InvoiceItem) => ({
item: item.description,
quantity: item.quantity,
unit_price: item.unitPrice,
amount: item.amount
})),
subtotal: data.subtotal,
tax: data.taxAmount,
tip: data.tipAmount,
total: data.total
}, null, 2);
// Check budget limits and show toast if needed
checkBudgetLimit(data.total);
// Apply the JSON data to the form
onDataChange({
invoiceData: data,
itemized_invoice: itemizedInvoice,
as_funding_required: true
});
toast.success('Invoice data applied successfully');
setShowJsonInput(false);
} catch (error) {
setJsonError('Invalid JSON format: ' + (error as Error).message);
}
};
// Handle drag events for file upload
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const newFiles = Array.from(e.dataTransfer.files) as File[];
// Combine existing files with new files instead of replacing
const combinedFiles = [...invoiceFiles, ...newFiles];
setInvoiceFiles(combinedFiles);
onDataChange({ invoice_files: combinedFiles });
}
};
// Handle invoice data change from the invoice builder
const handleInvoiceDataChange = (data: InvoiceData) => {
// Check budget limits and show toast if needed
checkBudgetLimit(data.total);
onDataChange({
invoiceData: data,
itemized_invoice: JSON.stringify(data)
});
};
return (
<motion.div
className="space-y-8"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div variants={itemVariants}>
<h2 className="text-3xl font-bold mb-4 text-primary bg-gradient-to-r from-primary to-primary-focus bg-clip-text text-transparent">
AS Funding Details
</h2>
</motion.div>
<motion.div variants={itemVariants}>
<CustomAlert
type="info"
title="AS Funding Information"
message="AS funding can cover food and other expenses for your event. Please itemize all expenses in the invoice builder below."
className="mb-6"
icon="heroicons:information-circle"
/>
</motion.div>
{/* Invoice Upload Section */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<h3 className="text-xl font-semibold mb-2 text-primary">Invoice Information</h3>
<p className="text-sm text-gray-500 mb-4">Upload your invoice files or create an itemized invoice below.</p>
<motion.div
className={`mt-2 border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${isDragging ? 'border-primary bg-primary/5' : 'border-base-300 hover:border-primary/50'
}`}
whileHover={{ scale: 1.02, boxShadow: "0 4px 8px rgba(0, 0, 0, 0.1)" }}
whileTap={{ scale: 0.98 }}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => document.getElementById('invoice-files')?.click()}
>
<input
id="invoice-files"
type="file"
className="hidden"
onChange={handleInvoiceFileChange}
accept=".pdf,.jpg,.jpeg,.png"
multiple
/>
<div className="flex flex-col items-center justify-center gap-3">
<motion.div
className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary"
whileHover={{ rotate: 15, scale: 1.1 }}
>
<Icon icon="heroicons:document-text" className="h-8 w-8" />
</motion.div>
{invoiceFiles.length > 0 ? (
<>
<div className="flex items-center justify-between w-full">
<p className="font-medium text-primary">{invoiceFiles.length} file(s) selected:</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleClearAllFiles();
}}
className="btn btn-xs btn-outline btn-error"
title="Clear all files"
>
Clear All
</button>
</div>
<div className="max-h-32 overflow-y-auto text-left w-full space-y-1">
{invoiceFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-base-100 p-2 rounded">
<span className="text-sm truncate flex-1">{file.name}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveFile(index);
}}
className="btn btn-xs btn-error ml-2"
title="Remove file"
>
<Icon icon="heroicons:x-mark" className="w-3 h-3" />
</button>
</div>
))}
</div>
<p className="text-xs text-gray-500">Click or drag to add more files</p>
</>
) : (
<>
<p className="font-medium">Drop your invoice files here or click to browse</p>
<p className="text-xs text-gray-500">Supports PDF, JPG, JPEG, PNG (multiple files allowed)</p>
</>
)}
</div>
</motion.div>
</motion.div>
{/* JSON/Builder Toggle */}
<motion.div
variants={itemVariants}
className="bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-bold text-primary">Invoice Details</h3>
<div className="flex mb-4 border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowJsonInput(false)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${!showJsonInput ? 'bg-primary text-white' : 'hover:bg-base-200'
}`}
>
Visual Editor
</button>
<button
type="button"
onClick={() => setShowJsonInput(true)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${showJsonInput ? 'bg-primary text-white' : 'hover:bg-base-200'
}`}
>
JSON Editor
</button>
</div>
</div>
{showJsonInput ? (
<motion.div
className="space-y-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="flex justify-between items-center">
<label className="label-text font-medium">JSON Invoice Data</label>
<motion.button
type="button"
className="btn btn-sm btn-ghost text-primary"
onClick={showJsonExample}
whileHover="hover"
whileTap="tap"
variants={buttonVariants}
>
<Icon icon="heroicons:code-bracket" className="w-4 h-4 mr-1" />
Show Example
</motion.button>
</div>
<motion.textarea
className={`textarea textarea-bordered w-full h-64 font-mono text-sm ${jsonError ? 'textarea-error' : 'focus:textarea-primary'}`}
value={jsonInput}
onChange={handleJsonInputChange}
placeholder="Paste your JSON invoice data here..."
whileHover="hover"
variants={inputHoverVariants}
/>
{jsonError && (
<div className="text-error text-sm flex items-center gap-1">
<Icon icon="heroicons:exclamation-circle" className="w-4 h-4" />
{jsonError}
</div>
)}
<div className="flex justify-end">
<motion.button
type="button"
className="btn btn-primary"
onClick={validateAndApplyJson}
whileHover="hover"
whileTap="tap"
variants={buttonVariants}
>
<Icon icon="heroicons:check-circle" className="w-5 h-5 mr-1" />
Apply JSON
</motion.button>
</div>
</motion.div>
) : (
<motion.div
variants={itemVariants}
className="form-control space-y-6"
>
<InvoiceBuilder
invoiceData={formData.invoiceData || {
vendor: '',
items: [],
subtotal: 0,
taxAmount: 0,
tipAmount: 0,
total: 0
}}
onChange={handleInvoiceDataChange}
/>
</motion.div>
)}
</motion.div>
</motion.div>
);
};
export default ASFundingSection;