ieeeucsd-org/src/components/dashboard/Officer_EventRequestForm/EventRequestForm.tsx
2025-03-13 02:35:57 -07:00

855 lines
No EOL
32 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import toast from 'react-hot-toast';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Update } from '../../../scripts/pocketbase/Update';
import { FileManager } from '../../../scripts/pocketbase/FileManager';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import { EventRequestStatus } from '../../../schemas/pocketbase';
// Form sections
import PRSection from './PRSection';
import EventDetailsSection from './EventDetailsSection';
import TAPFormSection from './TAPFormSection';
import ASFundingSection from './ASFundingSection';
import { EventRequestFormPreview } from './EventRequestFormPreview';
import type { InvoiceData, InvoiceItem } from './InvoiceBuilder';
// Animation variants
const containerVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 30,
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
// Form data interface - based on the schema EventRequest but with form-specific fields
export interface EventRequestFormData {
// Fields from EventRequest
name: string;
location: string;
start_date_time: string;
end_date_time: string;
event_description: string;
flyers_needed: boolean;
photography_needed: boolean;
as_funding_required: boolean;
food_drinks_being_served: boolean;
itemized_invoice?: string;
status?: string;
created_by?: string;
id?: string;
created?: string;
updated?: string;
// Additional form-specific fields
flyer_type: string[];
other_flyer_type: string;
flyer_advertising_start_date: string;
flyer_additional_requests: string;
required_logos: string[];
other_logos: File[]; // Form uses File objects, schema uses strings
advertising_format: string;
will_or_have_room_booking: boolean;
expected_attendance: number;
room_booking: File | null;
invoice: File | null;
invoice_files: File[];
invoiceData: InvoiceData;
needs_graphics?: boolean | null;
needs_as_funding?: boolean | null;
formReviewed?: boolean; // Track if the form has been reviewed
}
// Add CustomAlert import
import CustomAlert from '../universal/CustomAlert';
const EventRequestForm: React.FC = () => {
const [currentStep, setCurrentStep] = useState<number>(1);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Initialize form data
const [formData, setFormData] = useState<EventRequestFormData>({
name: '',
location: '',
start_date_time: '',
end_date_time: '',
event_description: '',
flyers_needed: false,
flyer_type: [],
other_flyer_type: '',
flyer_advertising_start_date: '',
flyer_additional_requests: '',
photography_needed: false,
required_logos: [],
other_logos: [],
advertising_format: '',
will_or_have_room_booking: false,
expected_attendance: 0,
room_booking: null,
as_funding_required: false,
food_drinks_being_served: false,
itemized_invoice: '',
invoice: null,
invoice_files: [], // Initialize empty array for multiple invoice files
needs_graphics: null,
needs_as_funding: false,
invoiceData: {
items: [],
subtotal: 0,
taxAmount: 0,
tipAmount: 0,
total: 0,
vendor: ''
},
formReviewed: false // Initialize as false
});
// Save form data to localStorage
useEffect(() => {
const formDataToSave = { ...formData };
// Remove file objects before saving to localStorage
const dataToStore = {
...formDataToSave,
other_logos: [],
room_booking: null,
invoice: null,
invoice_files: []
};
localStorage.setItem('eventRequestFormData', JSON.stringify(dataToStore));
// Also update the preview data
window.dispatchEvent(new CustomEvent('formDataUpdated', {
detail: { formData: formDataToSave }
}));
}, [formData]);
// Load form data from localStorage on initial load
useEffect(() => {
const savedData = localStorage.getItem('eventRequestFormData');
if (savedData) {
try {
const parsedData = JSON.parse(savedData);
setFormData(prevData => ({
...prevData,
...parsedData
}));
} catch (e) {
console.error('Error parsing saved form data:', e);
}
}
}, []);
// Handle form section data changes
const handleSectionDataChange = (sectionData: Partial<EventRequestFormData>) => {
// Ensure both needs_graphics and flyers_needed are synchronized
if (sectionData.flyers_needed !== undefined && sectionData.needs_graphics === undefined) {
sectionData.needs_graphics = sectionData.flyers_needed ? true : false;
}
// Ensure both needs_as_funding and as_funding_required are synchronized
if (sectionData.needs_as_funding !== undefined && sectionData.as_funding_required === undefined) {
sectionData.as_funding_required = sectionData.needs_as_funding ? true : false;
}
setFormData(prevData => {
// Save to localStorage
const updatedData = { ...prevData, ...sectionData };
localStorage.setItem('eventRequestFormData', JSON.stringify(updatedData));
return updatedData;
});
};
// Add this function before the handleSubmit function
const resetForm = () => {
setFormData({
name: '',
location: '',
start_date_time: '',
end_date_time: '',
event_description: '',
flyers_needed: false,
flyer_type: [],
other_flyer_type: '',
flyer_advertising_start_date: '',
flyer_additional_requests: '',
photography_needed: false,
required_logos: [],
other_logos: [],
advertising_format: '',
will_or_have_room_booking: false,
expected_attendance: 0,
room_booking: null, // No room booking by default
as_funding_required: false,
food_drinks_being_served: false,
itemized_invoice: '',
invoice: null,
invoice_files: [], // Reset multiple invoice files
needs_graphics: null,
needs_as_funding: false,
invoiceData: {
items: [],
subtotal: 0,
taxAmount: 0,
tipAmount: 0,
total: 0,
vendor: ''
},
formReviewed: false // Reset review status
});
// Reset to first step
setCurrentStep(1);
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Check if the form has been reviewed
if (!formData.formReviewed) {
toast.error('Please review your form before submitting');
return;
}
setIsSubmitting(true);
setError(null);
try {
const auth = Authentication.getInstance();
const update = Update.getInstance();
const fileManager = FileManager.getInstance();
const dataSync = DataSyncService.getInstance();
if (!auth.isAuthenticated()) {
// Don't show error toast on dashboard page for unauthenticated users
if (!window.location.pathname.includes('/dashboard')) {
toast.error('You must be logged in to submit an event request');
}
throw new Error('You must be logged in to submit an event request');
}
// Create the event request record
const userId = auth.getUserId();
if (!userId) {
// Don't show error toast on dashboard page for unauthenticated users
if (auth.isAuthenticated() || !window.location.pathname.includes('/dashboard')) {
toast.error('User ID not found');
}
throw new Error('User ID not found');
}
// Prepare data for submission
const submissionData = {
requested_user: userId,
name: formData.name,
location: formData.location,
start_date_time: new Date(formData.start_date_time).toISOString(),
end_date_time: formData.end_date_time ? new Date(formData.end_date_time).toISOString() : new Date(formData.start_date_time).toISOString(),
event_description: formData.event_description,
flyers_needed: formData.flyers_needed,
photography_needed: formData.photography_needed,
as_funding_required: formData.needs_as_funding,
food_drinks_being_served: formData.food_drinks_being_served,
itemized_invoice: formData.itemized_invoice,
flyer_type: formData.flyer_type,
other_flyer_type: formData.other_flyer_type,
flyer_advertising_start_date: formData.flyer_advertising_start_date ? new Date(formData.flyer_advertising_start_date).toISOString() : '',
flyer_additional_requests: formData.flyer_additional_requests,
required_logos: formData.required_logos,
advertising_format: formData.advertising_format,
will_or_have_room_booking: formData.will_or_have_room_booking,
expected_attendance: formData.expected_attendance,
needs_graphics: formData.needs_graphics,
needs_as_funding: formData.needs_as_funding,
invoice_data: {
items: formData.invoiceData.items.map(item => ({
item: item.description,
quantity: item.quantity,
unit_price: item.unitPrice
})),
taxAmount: formData.invoiceData.taxAmount,
tipAmount: formData.invoiceData.tipAmount,
total: formData.invoiceData.total,
vendor: formData.invoiceData.vendor
},
};
// Create the record using the Update service
// This will send the data to the server
const record = await update.create('event_request', submissionData);
// Force sync the event requests collection to update IndexedDB
await dataSync.syncCollection(Collections.EVENT_REQUESTS);
// Upload files if they exist
if (formData.other_logos.length > 0) {
await fileManager.uploadFiles('event_request', record.id, 'other_logos', formData.other_logos);
}
if (formData.room_booking) {
await fileManager.uploadFile('event_request', record.id, 'room_booking', formData.room_booking);
}
// Upload multiple invoice files
if (formData.invoice_files && formData.invoice_files.length > 0) {
await fileManager.appendFiles('event_request', record.id, 'invoice_files', formData.invoice_files);
// For backward compatibility, also upload the first file as the main invoice
if (formData.invoice || formData.invoice_files[0]) {
const mainInvoice = formData.invoice || formData.invoice_files[0];
await fileManager.uploadFile('event_request', record.id, 'invoice', mainInvoice);
}
} else if (formData.invoice) {
await fileManager.uploadFile('event_request', record.id, 'invoice', formData.invoice);
}
// Clear form data from localStorage
localStorage.removeItem('eventRequestFormData');
// Keep success toast for form submission since it's a user action
toast.success('Event request submitted successfully!');
// Reset form
resetForm();
// Switch to the submissions tab
const submissionsTab = document.getElementById('submissions-tab');
if (submissionsTab) {
submissionsTab.click();
}
} catch (error) {
console.error('Error submitting event request:', error);
toast.error('Failed to submit event request. Please try again.');
setError('Failed to submit event request. Please try again.');
} finally {
setIsSubmitting(false);
}
};
// Validate PR Section
const validatePRSection = () => {
if (formData.flyer_type.length === 0) {
toast.error('Please select at least one flyer type');
return false;
}
if (formData.flyer_type.includes('other') && !formData.other_flyer_type) {
toast.error('Please specify the other flyer type');
return false;
}
if (formData.flyer_type.some(type =>
type === 'digital_with_social' ||
type === 'physical_with_advertising' ||
type === 'newsletter'
) && !formData.flyer_advertising_start_date) {
toast.error('Please specify when to start advertising');
return false;
}
if (formData.required_logos.includes('OTHER') && (!formData.other_logos || formData.other_logos.length === 0)) {
toast.error('Please upload your logo files');
return false;
}
if (!formData.advertising_format) {
toast.error('Please select a format');
return false;
}
if (formData.photography_needed === null || formData.photography_needed === undefined) {
toast.error('Please specify if photography is needed');
return false;
}
return true;
};
// Validate Event Details Section
const validateEventDetailsSection = () => {
let valid = true;
const errors: string[] = [];
if (!formData.name || formData.name.trim() === '') {
errors.push('Event name is required');
valid = false;
}
if (!formData.event_description || formData.event_description.trim() === '') {
errors.push('Event description is required');
valid = false;
}
if (!formData.start_date_time || formData.start_date_time.trim() === '') {
errors.push('Event start date and time is required');
valid = false;
}
if (!formData.end_date_time) {
errors.push('Event end time is required');
valid = false;
}
if (!formData.location || formData.location.trim() === '') {
errors.push('Event location is required');
valid = false;
}
if (formData.will_or_have_room_booking === undefined) {
errors.push('Room booking status is required');
valid = false;
}
if (errors.length > 0) {
setError(errors[0]);
return false;
}
return valid;
};
// Validate TAP Form Section
const validateTAPFormSection = () => {
// Verify that all required fields are filled
if (!formData.will_or_have_room_booking && formData.will_or_have_room_booking !== false) {
toast.error('Please indicate whether you will or have a room booking');
return false;
}
if (!formData.expected_attendance || formData.expected_attendance <= 0) {
toast.error('Please enter a valid expected attendance');
return false;
}
// Only require room booking file if will_or_have_room_booking is true
if (formData.will_or_have_room_booking && !formData.room_booking) {
toast.error('Please upload your room booking confirmation');
return false;
}
if (!formData.food_drinks_being_served && formData.food_drinks_being_served !== false) {
toast.error('Please indicate whether food/drinks will be served');
return false;
}
// Validate AS funding question if food is being served
if (formData.food_drinks_being_served && formData.needs_as_funding === undefined) {
toast.error('Please indicate whether you need AS funding');
return false;
}
return true;
};
// Validate AS Funding Section
const validateASFundingSection = () => {
if (formData.as_funding_required) {
// Check if invoice data is present and has items
if (!formData.invoiceData || !formData.invoiceData.items || formData.invoiceData.items.length === 0) {
setError('Please add at least one item to your invoice');
return false;
}
// Calculate the total budget from invoice items
const totalBudget = formData.invoiceData.items.reduce(
(sum, item) => sum + (item.unitPrice * item.quantity), 0
);
// Check if the budget exceeds the maximum allowed ($5000 cap regardless of attendance)
const maxBudget = Math.min(formData.expected_attendance * 10, 5000);
if (totalBudget > maxBudget) {
setError(`Your budget (${totalBudget.toFixed(2)} dollars) exceeds the maximum allowed (${maxBudget} dollars). The absolute maximum is $5,000.`);
return false;
}
}
return true;
};
// Validate all sections before submission
const validateAllSections = () => {
// We no longer forcibly set end_date_time to match start_date_time
// The end time is now configured separately with the same date
if (!validateEventDetailsSection()) {
setCurrentStep(1);
return false;
}
// Validate TAP Form
if (!validateTAPFormSection()) {
return false;
}
// Validate PR Section if needed
if (formData.needs_graphics && !validatePRSection()) {
return false;
}
// Validate AS Funding if needed
if (formData.food_drinks_being_served && formData.needs_as_funding && !validateASFundingSection()) {
return false;
}
return true;
};
// Handle next button click with validation
const handleNextStep = (nextStep: number) => {
let isValid = true;
// Validate current section before proceeding
if (currentStep === 2 && formData.needs_graphics) {
isValid = validatePRSection();
} else if (currentStep === 3) {
isValid = validateEventDetailsSection();
} else if (currentStep === 4) {
isValid = validateTAPFormSection();
} else if (currentStep === 5 && formData.food_drinks_being_served && formData.needs_as_funding) {
isValid = validateASFundingSection();
}
if (!isValid) {
return; // Don't proceed if validation fails
}
// If we're moving from step 4 to step 5
if (currentStep === 4 && nextStep === 5) {
// If food and drinks aren't being served or if AS funding isn't needed, skip to step 6 (review)
if (!formData.food_drinks_being_served || !formData.needs_as_funding) {
nextStep = 6;
}
}
// Set the current step
setCurrentStep(nextStep);
// If moving to the review step, mark the form as reviewed
// but don't submit it automatically
if (nextStep === 6) {
setFormData(prevData => ({
...prevData,
formReviewed: true
}));
}
};
// Handle form submission with validation
const handleSubmitWithValidation = (e: React.FormEvent) => {
e.preventDefault();
// If we're on the review step, we've already validated all sections
// Only submit if the user explicitly clicks the submit button
if (currentStep === 6 && formData.formReviewed) {
handleSubmit(e);
return;
}
// Otherwise, validate all sections before proceeding to the review step
if (validateAllSections()) {
// If we're not on the review step, go to the review step instead of submitting
handleNextStep(6);
}
};
// Render the current step
const renderCurrentSection = () => {
// Step 1: Ask if they need graphics from the design team
if (currentStep === 1) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="space-y-6"
>
<CustomAlert
type="warning"
title="Multiple Events Notice"
message="If you have multiple events, you must submit a separate TAP form for each one. Multiple-day events require individual submissions for each day."
icon="heroicons:exclamation-triangle"
className="mb-4"
/>
<h2 className="text-2xl font-bold mb-4 text-primary">Event Request Form</h2>
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
<p className="text-sm">
Welcome to the IEEE UCSD Event Request Form. This form will help you request PR materials,
provide event details, and request AS funding if needed.
</p>
</div>
<div className="bg-base-200/50 p-6 rounded-lg">
<h3 className="text-xl font-semibold mb-4">Do you need graphics from the design team?</h3>
<div className="flex flex-col sm:flex-row gap-4">
<button
className={`btn btn-lg ${formData.needs_graphics ? 'btn-primary' : 'btn-outline'} flex-1`}
onClick={() => {
setFormData({ ...formData, needs_graphics: true, flyers_needed: true });
setCurrentStep(2);
}}
>
Yes
</button>
<button
className={`btn btn-lg ${!formData.needs_graphics && formData.needs_graphics !== null ? 'btn-primary' : 'btn-outline'} flex-1`}
onClick={() => {
setFormData({ ...formData, needs_graphics: false, flyers_needed: false });
setCurrentStep(3);
}}
>
No
</button>
</div>
</div>
</motion.div>
);
}
// Step 2: PR Section (if they need graphics)
if (currentStep === 2) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="space-y-6"
>
<PRSection formData={formData} onDataChange={handleSectionDataChange} />
<div className="flex justify-between mt-8">
<button className="btn btn-outline" onClick={() => setCurrentStep(1)}>
Back
</button>
<button className="btn btn-primary" onClick={() => handleNextStep(3)}>
Next
</button>
</div>
</motion.div>
);
}
// Step 3: Event Details Section
if (currentStep === 3) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="space-y-6"
>
<EventDetailsSection formData={formData} onDataChange={handleSectionDataChange} />
<div className="flex justify-between mt-8">
<button className="btn btn-outline" onClick={() => setCurrentStep(formData.needs_graphics ? 2 : 1)}>
Back
</button>
<button className="btn btn-primary" onClick={() => handleNextStep(4)}>
Next
</button>
</div>
</motion.div>
);
}
// Step 4: TAP Form Section
if (currentStep === 4) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="space-y-6"
>
<TAPFormSection formData={formData} onDataChange={handleSectionDataChange} />
<div className="flex justify-between mt-8">
<button className="btn btn-outline" onClick={() => setCurrentStep(3)}>
Back
</button>
<button className="btn btn-primary" onClick={() => handleNextStep(5)}>
Next
</button>
</div>
</motion.div>
);
}
// Step 5: AS Funding Section
if (currentStep === 5) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="space-y-6"
>
<div className="bg-base-200/50 p-6 rounded-lg mb-6">
<h3 className="text-xl font-semibold mb-4">AS Funding Details</h3>
<p className="mb-4">Please provide the necessary information for your AS funding request.</p>
</div>
<ASFundingSection formData={formData} onDataChange={handleSectionDataChange} />
<div className="flex justify-between mt-8">
<button className="btn btn-outline" onClick={() => setCurrentStep(4)}>
Back
</button>
<button className="btn btn-primary" onClick={() => handleNextStep(6)}>
Next
</button>
</div>
</motion.div>
);
}
// Step 6: Review Form
if (currentStep === 6) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="space-y-6"
>
<h2 className="text-2xl font-bold mb-4 text-primary">Review Your Event Request</h2>
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
<p className="text-sm">
Please review all information carefully before submitting. You can go back to any section to make changes if needed.
</p>
</div>
<div className="bg-base-200/50 p-6 rounded-lg">
<EventRequestFormPreview formData={formData} isModal={false} />
<div className="divider my-6">Ready to Submit?</div>
<CustomAlert
type="info"
title="Important Note"
message="Once submitted, you'll need to notify PR and/or Coordinators in the #-events Slack channel."
icon="heroicons:information-circle"
className="mb-6"
/>
</div>
<div className="flex justify-between mt-8">
<button
className="btn btn-outline"
onClick={() => {
// Skip the AS Funding section if not needed
if (!formData.food_drinks_being_served || !formData.needs_as_funding) {
setCurrentStep(4); // Go back to TAP Form section
} else {
setCurrentStep(5); // Go back to AS Funding section
}
}}
>
Back
</button>
<button
type="button"
className="btn btn-success btn-lg"
onClick={handleSubmitWithValidation}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<span className="loading loading-spinner"></span>
Submitting...
</>
) : (
'Submit Event Request'
)}
</button>
</div>
</motion.div>
);
}
return null;
};
return (
<>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="max-w-4xl mx-auto"
>
<form
onSubmit={(e) => {
// Prevent default form submission behavior
e.preventDefault();
// Only submit if the user explicitly clicks the submit button
// The actual submission is handled by handleSubmitWithValidation
}}
className="space-y-6"
>
{error && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
>
<CustomAlert
type="error"
title="Error"
message={error}
icon="heroicons:exclamation-triangle"
/>
</motion.div>
)}
{/* Progress indicator */}
<div className="w-full mb-6">
<div className="flex justify-between mb-2">
<span className="text-sm font-medium">Step {currentStep} of 6</span>
<span className="text-sm font-medium">{Math.min(Math.round((currentStep / 6) * 100), 100)}% complete</span>
</div>
<div className="w-full bg-base-300 rounded-full h-2.5">
<div
className="bg-primary h-2.5 rounded-full transition-all duration-300"
style={{ width: `${Math.min((currentStep / 6) * 100, 100)}%` }}
></div>
</div>
</div>
{/* Current section */}
<AnimatePresence mode="wait">
{renderCurrentSection()}
</AnimatePresence>
</form>
</motion.div>
</>
);
};
export default EventRequestForm;