added event revisions based on erik
This commit is contained in:
parent
f28a076cfb
commit
afc5708e21
11 changed files with 624 additions and 258 deletions
|
@ -217,26 +217,14 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
|
|||
}
|
||||
};
|
||||
|
||||
// Handle invoice data change
|
||||
// Handle invoice data change from the invoice builder
|
||||
const handleInvoiceDataChange = (data: InvoiceData) => {
|
||||
// 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);
|
||||
// Calculate if budget exceeds maximum allowed
|
||||
const maxBudget = Math.min(formData.expected_attendance * 10, 5000);
|
||||
|
||||
onDataChange({
|
||||
invoiceData: data,
|
||||
itemized_invoice: itemizedInvoice
|
||||
itemized_invoice: JSON.stringify(data)
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -251,18 +239,15 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
|
|||
<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>
|
||||
<p className="text-lg text-base-content/80 mb-6">
|
||||
Please provide the necessary information for your Associated Students funding request.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants}>
|
||||
<CustomAlert
|
||||
type="warning"
|
||||
title="Important Deadline"
|
||||
message="AS Funding requests must be submitted at least 6 weeks before your event. Please check the Funding Guide or the Google Calendar for the funding request deadlines."
|
||||
className="mb-4"
|
||||
icon="heroicons:clock"
|
||||
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>
|
||||
|
||||
|
@ -407,18 +392,15 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
|
|||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
variants={itemVariants}
|
||||
className="form-control space-y-6"
|
||||
>
|
||||
<InvoiceBuilder
|
||||
invoiceData={formData.invoiceData || {
|
||||
vendor: '',
|
||||
items: [],
|
||||
subtotal: 0,
|
||||
taxRate: 0,
|
||||
taxAmount: 0,
|
||||
tipPercentage: 0,
|
||||
tipAmount: 0,
|
||||
total: 0
|
||||
}}
|
||||
|
|
|
@ -114,7 +114,7 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
|
|||
{/* Date and Time Section */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-6"
|
||||
className="grid grid-cols-1 gap-6"
|
||||
>
|
||||
{/* Event Start Date */}
|
||||
<motion.div
|
||||
|
@ -122,7 +122,7 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
|
|||
whileHover={{ y: -2 }}
|
||||
>
|
||||
<label className="label">
|
||||
<span className="label-text font-medium text-lg">Event Start</span>
|
||||
<span className="label-text font-medium text-lg">Event Start Date & Time</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<motion.input
|
||||
|
@ -134,26 +134,48 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
|
|||
whileHover="hover"
|
||||
variants={inputHoverVariants}
|
||||
/>
|
||||
<p className="text-sm text-base-content/70 mt-2">
|
||||
Note: For multi-day events, please submit a separate request for each day.
|
||||
</p>
|
||||
<p className="text-sm text-base-content/70 mt-1">
|
||||
The event time should not include setup time.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Event End Date */}
|
||||
{/* Event End Time */}
|
||||
<motion.div
|
||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
||||
whileHover={{ y: -2 }}
|
||||
>
|
||||
<label className="label">
|
||||
<span className="label-text font-medium text-lg">Event End</span>
|
||||
<span className="label-text font-medium text-lg">Event End Time</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<motion.input
|
||||
type="datetime-local"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
|
||||
value={formData.end_date_time}
|
||||
onChange={(e) => onDataChange({ end_date_time: e.target.value })}
|
||||
required
|
||||
whileHover="hover"
|
||||
variants={inputHoverVariants}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<motion.input
|
||||
type="time"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={formData.end_date_time ? new Date(formData.end_date_time).toTimeString().substring(0, 5) : ''}
|
||||
onChange={(e) => {
|
||||
if (formData.start_date_time) {
|
||||
// Create a new date object from start_date_time
|
||||
const startDate = new Date(formData.start_date_time);
|
||||
// Parse the time value
|
||||
const [hours, minutes] = e.target.value.split(':').map(Number);
|
||||
// Set the hours and minutes on the date
|
||||
startDate.setHours(hours, minutes);
|
||||
// Update end_date_time with the new time but same date as start
|
||||
onDataChange({ end_date_time: startDate.toISOString() });
|
||||
}
|
||||
}}
|
||||
required
|
||||
whileHover="hover"
|
||||
variants={inputHoverVariants}
|
||||
/>
|
||||
<p className="text-xs text-base-content/60">
|
||||
The end time will use the same date as the start date.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
|
@ -202,7 +224,7 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
|
|||
onChange={() => onDataChange({ will_or_have_room_booking: true })}
|
||||
required
|
||||
/>
|
||||
<span className="font-medium">Yes, I have/will have a booking</span>
|
||||
<span className="font-medium">Yes, I have a room booking</span>
|
||||
</motion.label>
|
||||
<motion.label
|
||||
className="flex items-center gap-3 cursor-pointer bg-base-100 p-3 rounded-lg hover:bg-primary/10 transition-colors"
|
||||
|
@ -213,12 +235,29 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
|
|||
type="radio"
|
||||
className="radio radio-primary"
|
||||
checked={formData.will_or_have_room_booking === false}
|
||||
onChange={() => onDataChange({ will_or_have_room_booking: false })}
|
||||
onChange={() => {
|
||||
onDataChange({ will_or_have_room_booking: false });
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<span className="font-medium">No, I don't need a booking</span>
|
||||
</motion.label>
|
||||
</div>
|
||||
|
||||
{formData.will_or_have_room_booking === false && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-4"
|
||||
>
|
||||
<CustomAlert
|
||||
type="warning"
|
||||
title="IMPORTANT: Event Will Be Cancelled"
|
||||
message="If you need a booking and submit without one, your event WILL BE CANCELLED. This is non-negotiable. Contact the event coordinator immediately if you have any booking concerns."
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
|
|
|
@ -13,7 +13,7 @@ import PRSection from './PRSection';
|
|||
import EventDetailsSection from './EventDetailsSection';
|
||||
import TAPFormSection from './TAPFormSection';
|
||||
import ASFundingSection from './ASFundingSection';
|
||||
import EventRequestFormPreview from './EventRequestFormPreview';
|
||||
import { EventRequestFormPreview } from './EventRequestFormPreview';
|
||||
import type { InvoiceData, InvoiceItem } from './InvoiceBuilder';
|
||||
|
||||
// Animation variants
|
||||
|
@ -119,9 +119,7 @@ const EventRequestForm: React.FC = () => {
|
|||
invoiceData: {
|
||||
items: [],
|
||||
subtotal: 0,
|
||||
taxRate: 7.75, // Default tax rate for San Diego
|
||||
taxAmount: 0,
|
||||
tipPercentage: 15, // Default tip percentage
|
||||
tipAmount: 0,
|
||||
total: 0,
|
||||
vendor: ''
|
||||
|
@ -204,7 +202,7 @@ const EventRequestForm: React.FC = () => {
|
|||
advertising_format: '',
|
||||
will_or_have_room_booking: false,
|
||||
expected_attendance: 0,
|
||||
room_booking: null,
|
||||
room_booking: null, // No room booking by default
|
||||
as_funding_required: false,
|
||||
food_drinks_being_served: false,
|
||||
itemized_invoice: '',
|
||||
|
@ -215,9 +213,7 @@ const EventRequestForm: React.FC = () => {
|
|||
invoiceData: {
|
||||
items: [],
|
||||
subtotal: 0,
|
||||
taxRate: 7.75, // Default tax rate for San Diego
|
||||
taxAmount: 0,
|
||||
tipPercentage: 15, // Default tip percentage
|
||||
tipAmount: 0,
|
||||
total: 0,
|
||||
vendor: ''
|
||||
|
@ -272,13 +268,12 @@ const EventRequestForm: React.FC = () => {
|
|||
name: formData.name,
|
||||
location: formData.location,
|
||||
start_date_time: new Date(formData.start_date_time).toISOString(),
|
||||
end_date_time: new Date(formData.end_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,
|
||||
// Store the itemized_invoice as a string for backward compatibility
|
||||
itemized_invoice: formData.itemized_invoice,
|
||||
flyer_type: formData.flyer_type,
|
||||
other_flyer_type: formData.other_flyer_type,
|
||||
|
@ -288,23 +283,19 @@ const EventRequestForm: React.FC = () => {
|
|||
advertising_format: formData.advertising_format,
|
||||
will_or_have_room_booking: formData.will_or_have_room_booking,
|
||||
expected_attendance: formData.expected_attendance,
|
||||
// Add these fields explicitly to match the schema
|
||||
needs_graphics: formData.needs_graphics,
|
||||
needs_as_funding: formData.needs_as_funding,
|
||||
// Store the invoice data as a properly formatted JSON object
|
||||
invoice_data: {
|
||||
items: formData.invoiceData.items.map(item => ({
|
||||
item: item.description,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unitPrice
|
||||
})),
|
||||
tax: formData.invoiceData.taxAmount,
|
||||
tip: formData.invoiceData.tipAmount,
|
||||
taxAmount: formData.invoiceData.taxAmount,
|
||||
tipAmount: formData.invoiceData.tipAmount,
|
||||
total: formData.invoiceData.total,
|
||||
vendor: formData.invoiceData.vendor
|
||||
},
|
||||
// Set the initial status to "submitted"
|
||||
status: EventRequestStatus.SUBMITTED,
|
||||
};
|
||||
|
||||
// Create the record using the Update service
|
||||
|
@ -400,37 +391,45 @@ const EventRequestForm: React.FC = () => {
|
|||
|
||||
// Validate Event Details Section
|
||||
const validateEventDetailsSection = () => {
|
||||
if (!formData.name) {
|
||||
toast.error('Please enter an event name');
|
||||
return false;
|
||||
let valid = true;
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!formData.name || formData.name.trim() === '') {
|
||||
errors.push('Event name is required');
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!formData.event_description) {
|
||||
toast.error('Please enter an event description');
|
||||
return false;
|
||||
if (!formData.event_description || formData.event_description.trim() === '') {
|
||||
errors.push('Event description is required');
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!formData.start_date_time) {
|
||||
toast.error('Please enter a start date and time');
|
||||
return 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) {
|
||||
toast.error('Please enter an end date and 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;
|
||||
}
|
||||
|
||||
if (!formData.location) {
|
||||
toast.error('Please enter an event location');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.will_or_have_room_booking === null || formData.will_or_have_room_booking === undefined) {
|
||||
toast.error('Please specify if you have a room booking');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return valid;
|
||||
};
|
||||
|
||||
// Validate TAP Form Section
|
||||
|
@ -446,6 +445,7 @@ const EventRequestForm: React.FC = () => {
|
|||
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;
|
||||
|
@ -467,17 +467,22 @@ const EventRequestForm: React.FC = () => {
|
|||
|
||||
// Validate AS Funding Section
|
||||
const validateASFundingSection = () => {
|
||||
if (formData.needs_as_funding) {
|
||||
// Check if vendor is provided
|
||||
if (!formData.invoiceData.vendor) {
|
||||
toast.error('Please enter the vendor/restaurant name');
|
||||
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;
|
||||
}
|
||||
|
||||
// No longer require items in the invoice
|
||||
// Check if at least one invoice file is uploaded
|
||||
if (!formData.invoice && (!formData.invoice_files || formData.invoice_files.length === 0)) {
|
||||
toast.error('Please upload at least one invoice file');
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
@ -487,8 +492,11 @@ const EventRequestForm: React.FC = () => {
|
|||
|
||||
// Validate all sections before submission
|
||||
const validateAllSections = () => {
|
||||
// Validate Event Details
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
@ -579,6 +587,14 @@ const EventRequestForm: React.FC = () => {
|
|||
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">
|
||||
|
|
|
@ -65,6 +65,20 @@ const modalVariants = {
|
|||
}
|
||||
};
|
||||
|
||||
// Extended version of InvoiceItem to handle multiple property names
|
||||
interface ExtendedInvoiceItem {
|
||||
id?: string;
|
||||
description?: string;
|
||||
item?: string;
|
||||
name?: string;
|
||||
quantity: number;
|
||||
unitPrice?: number;
|
||||
unit_price?: number;
|
||||
price?: number;
|
||||
amount?: number;
|
||||
[key: string]: any; // Allow any additional properties
|
||||
}
|
||||
|
||||
// Helper function to normalize EventRequest to match EventRequestFormData structure
|
||||
const normalizeFormData = (data: EventRequestFormData | (EventRequest & {
|
||||
invoiceData?: any;
|
||||
|
@ -108,7 +122,26 @@ const normalizeFormData = (data: EventRequestFormData | (EventRequest & {
|
|||
...invoiceData,
|
||||
...(parsed as any),
|
||||
items: Array.isArray((parsed as any).items) ? (parsed as any).items : [],
|
||||
// Normalize tax/tip fields
|
||||
taxAmount: parseFloat(parsed.taxAmount || parsed.tax || 0),
|
||||
tipAmount: parseFloat(parsed.tipAmount || parsed.tip || 0)
|
||||
};
|
||||
|
||||
// Normalize item property names
|
||||
invoiceData.items = invoiceData.items.map(item => {
|
||||
// Create a normalized item with all possible property names
|
||||
return {
|
||||
id: item.id || `item-${Math.random().toString(36).substr(2, 9)}`,
|
||||
description: item.description || item.item || item.name || 'Item',
|
||||
quantity: parseFloat(item.quantity) || 1,
|
||||
unitPrice: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
|
||||
unit_price: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
|
||||
price: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
|
||||
amount: parseFloat(item.amount) ||
|
||||
(parseFloat(item.quantity) || 1) *
|
||||
parseFloat(item.unitPrice || item.unit_price || item.price || 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing itemized_invoice:', e);
|
||||
|
@ -119,7 +152,25 @@ const normalizeFormData = (data: EventRequestFormData | (EventRequest & {
|
|||
...invoiceData,
|
||||
...parsed,
|
||||
items: Array.isArray(parsed.items) ? parsed.items : [],
|
||||
// Normalize tax/tip fields
|
||||
taxAmount: parseFloat(parsed.taxAmount || parsed.tax || 0),
|
||||
tipAmount: parseFloat(parsed.tipAmount || parsed.tip || 0)
|
||||
};
|
||||
|
||||
// Normalize item property names
|
||||
invoiceData.items = invoiceData.items.map(item => {
|
||||
return {
|
||||
id: item.id || `item-${Math.random().toString(36).substr(2, 9)}`,
|
||||
description: item.description || item.item || item.name || 'Item',
|
||||
quantity: parseFloat(item.quantity) || 1,
|
||||
unitPrice: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
|
||||
unit_price: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
|
||||
price: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
|
||||
amount: parseFloat(item.amount) ||
|
||||
(parseFloat(item.quantity) || 1) *
|
||||
parseFloat(item.unitPrice || item.unit_price || item.price || 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
} else if (eventRequest.invoiceData) {
|
||||
const parsed = eventRequest.invoiceData as any;
|
||||
|
@ -128,7 +179,25 @@ const normalizeFormData = (data: EventRequestFormData | (EventRequest & {
|
|||
...invoiceData,
|
||||
...parsed,
|
||||
items: Array.isArray(parsed.items) ? parsed.items : [],
|
||||
// Normalize tax/tip fields
|
||||
taxAmount: parseFloat(parsed.taxAmount || parsed.tax || 0),
|
||||
tipAmount: parseFloat(parsed.tipAmount || parsed.tip || 0)
|
||||
};
|
||||
|
||||
// Normalize item property names
|
||||
invoiceData.items = invoiceData.items.map(item => {
|
||||
return {
|
||||
id: item.id || `item-${Math.random().toString(36).substr(2, 9)}`,
|
||||
description: item.description || item.item || item.name || 'Item',
|
||||
quantity: parseFloat(item.quantity) || 1,
|
||||
unitPrice: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
|
||||
unit_price: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
|
||||
price: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
|
||||
amount: parseFloat(item.amount) ||
|
||||
(parseFloat(item.quantity) || 1) *
|
||||
parseFloat(item.unitPrice || item.unit_price || item.price || 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -288,7 +357,7 @@ interface EventRequestFormPreviewProps {
|
|||
}); // Accept both form data and event request types
|
||||
isOpen?: boolean; // Control whether the modal is open
|
||||
onClose?: () => void; // Callback when modal is closed
|
||||
isModal?: boolean; // Whether to render as a modal or inline component
|
||||
isModal: boolean; // Whether to render as a modal or inline component
|
||||
}
|
||||
|
||||
// Define the main EventRequestFormPreview component
|
||||
|
@ -495,22 +564,22 @@ const EventRequestFormPreview: React.FC<EventRequestFormPreviewProps> = ({
|
|||
<span className="text-sm">Expected Attendance</span>
|
||||
</div>
|
||||
<p className="font-medium text-base-content">{formData.expected_attendance || 'Not specified'}</p>
|
||||
{formData.expected_attendance > 0 && (
|
||||
<p className="text-xs text-primary">
|
||||
Budget limit: ${Math.min(formData.expected_attendance * 10, 5000)} (max $5,000)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center text-base-content/60">
|
||||
<Icon icon="heroicons:calendar" className="w-4 h-4 mr-1" />
|
||||
<span className="text-sm">Start Date & Time</span>
|
||||
<span className="text-sm">Date & Time</span>
|
||||
</div>
|
||||
<p className="font-medium text-base-content">{formatDateTime(formData.start_date_time)}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center text-base-content/60">
|
||||
<Icon icon="heroicons:calendar" className="w-4 h-4 mr-1" />
|
||||
<span className="text-sm">End Date & Time</span>
|
||||
</div>
|
||||
<p className="font-medium text-base-content">{formatDateTime(formData.end_date_time)}</p>
|
||||
<p className="text-xs text-base-content/60">
|
||||
Note: Multi-day events require separate submissions for each day.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
|
@ -637,7 +706,7 @@ const EventRequestFormPreview: React.FC<EventRequestFormPreviewProps> = ({
|
|||
</h4>
|
||||
<div className="flex items-center">
|
||||
<span className={`badge ${formData.will_or_have_room_booking ? 'badge-success' : 'badge-neutral'}`}>
|
||||
{formData.will_or_have_room_booking ? 'Has/Will Have Booking' : 'No Booking Needed'}
|
||||
{formData.will_or_have_room_booking ? 'Room Booking Confirmed' : 'No Booking Needed'}
|
||||
</span>
|
||||
|
||||
{formData.will_or_have_room_booking && formData.room_booking && (
|
||||
|
@ -690,12 +759,12 @@ const EventRequestFormPreview: React.FC<EventRequestFormPreviewProps> = ({
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formData.invoiceData.items.map((item: InvoiceItem, index: number) => (
|
||||
{formData.invoiceData.items.map((item: ExtendedInvoiceItem, index: number) => (
|
||||
<tr key={item.id || index} className="border-t border-base-300">
|
||||
<td className="py-2 font-medium text-base-content">{item.description || 'Item'}</td>
|
||||
<td className="py-2 font-medium text-base-content">{item.description || item.item || item.name || 'Item'}</td>
|
||||
<td className="py-2 text-right text-base-content">{item.quantity || 1}</td>
|
||||
<td className="py-2 text-right text-base-content">${(item.unitPrice || 0).toFixed(2)}</td>
|
||||
<td className="py-2 text-right font-medium text-base-content">${(item.amount || 0).toFixed(2)}</td>
|
||||
<td className="py-2 text-right text-base-content">${(item.unitPrice || item.unit_price || item.price || 0).toFixed(2)}</td>
|
||||
<td className="py-2 text-right font-medium text-base-content">${(item.amount || (item.quantity * (item.unitPrice || item.unit_price || item.price || 0)) || 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
@ -704,18 +773,14 @@ const EventRequestFormPreview: React.FC<EventRequestFormPreviewProps> = ({
|
|||
<td colSpan={3} className="py-2 text-right font-medium text-base-content">Subtotal:</td>
|
||||
<td className="py-2 text-right font-medium text-base-content">${(formData.invoiceData.subtotal || 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
{formData.invoiceData.taxAmount && formData.invoiceData.taxAmount > 0 && (
|
||||
<tr className="border-t border-base-300">
|
||||
<td colSpan={3} className="py-2 text-right font-medium text-base-content">Tax:</td>
|
||||
<td className="py-2 text-right font-medium text-base-content">${(formData.invoiceData.taxAmount || 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
)}
|
||||
{formData.invoiceData.tipAmount && formData.invoiceData.tipAmount > 0 && (
|
||||
<tr className="border-t border-base-300">
|
||||
<td colSpan={3} className="py-2 text-right font-medium text-base-content">Tip:</td>
|
||||
<td className="py-2 text-right font-medium text-base-content">${(formData.invoiceData.tipAmount || 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr className="border-t border-base-300">
|
||||
<td colSpan={3} className="py-2 text-right font-medium text-base-content">Tax:</td>
|
||||
<td className="py-2 text-right font-medium text-base-content">${(typeof formData.invoiceData.taxAmount === 'number' ? formData.invoiceData.taxAmount : 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
<tr className="border-t border-base-300">
|
||||
<td colSpan={3} className="py-2 text-right font-medium text-base-content">Tip:</td>
|
||||
<td className="py-2 text-right font-medium text-base-content">${(typeof formData.invoiceData.tipAmount === 'number' ? formData.invoiceData.tipAmount : 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
<tr className="bg-primary/5">
|
||||
<td colSpan={3} className="py-2 text-right font-bold text-primary">Total:</td>
|
||||
<td className="py-2 text-right font-bold text-primary">${(formData.invoiceData.total || 0).toFixed(2)}</td>
|
||||
|
@ -730,28 +795,7 @@ const EventRequestFormPreview: React.FC<EventRequestFormPreviewProps> = ({
|
|||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Submission Information */}
|
||||
<motion.div
|
||||
variants={sectionVariants}
|
||||
className="bg-base-100 rounded-xl border border-base-300/50 shadow-sm overflow-hidden mt-8"
|
||||
>
|
||||
<div className="p-4 flex justify-between items-center border-b border-base-300/30">
|
||||
<h4 className="font-medium flex items-center">
|
||||
<Icon icon="heroicons:check-circle" className="w-5 h-5 mr-2 text-success" />
|
||||
Ready to Submit
|
||||
</h4>
|
||||
|
||||
{formData.formReviewed && (
|
||||
<span className="badge badge-success">Reviewed</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-base-100/50">
|
||||
<p className="text-sm text-base-content/70">
|
||||
Once submitted, you'll need to notify PR and/or Coordinators in the #-events Slack channel.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -713,6 +713,17 @@ const InvoiceBuilder: React.FC<InvoiceBuilderProps> = ({ invoiceData, onChange }
|
|||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Budget Warning */}
|
||||
{total > 0 && (
|
||||
<CustomAlert
|
||||
type="warning"
|
||||
title="BUDGET RESTRICTION"
|
||||
message="Your total cannot exceed $10 per expected attendee, with an absolute maximum of $5,000. Your form WILL BE REJECTED if it exceeds this limit."
|
||||
className="mt-4"
|
||||
icon="heroicons:exclamation-triangle"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Validation notice */}
|
||||
{invoiceData.items.length === 0 && (
|
||||
<CustomAlert
|
||||
|
@ -732,6 +743,42 @@ const InvoiceBuilder: React.FC<InvoiceBuilderProps> = ({ invoiceData, onChange }
|
|||
className="mt-4"
|
||||
icon="heroicons:exclamation-triangle"
|
||||
/>
|
||||
|
||||
{/* Summary Section */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="mt-6 bg-base-200/40 rounded-lg p-4"
|
||||
>
|
||||
<div className=" md:grid-cols-2 gap-6">
|
||||
|
||||
|
||||
{/* Right Column: Summary */}
|
||||
<div>
|
||||
<h3 className="font-medium text-lg mb-3">Invoice Summary</h3>
|
||||
<div className="bg-base-100/50 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm">Subtotal:</span>
|
||||
<span className="font-medium">${invoiceData.subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm">Tax:</span>
|
||||
<span className="font-medium">${invoiceData.taxAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm">Tip:</span>
|
||||
<span className="font-medium">${invoiceData.tipAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="divider my-1"></div>
|
||||
<div className="flex justify-between items-center font-bold text-primary">
|
||||
<span>Total:</span>
|
||||
<span>${invoiceData.total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { EventRequestFormData } from './EventRequestForm';
|
||||
import type { EventRequest } from '../../../schemas/pocketbase';
|
||||
import CustomAlert from '../universal/CustomAlert';
|
||||
import FilePreview from '../universal/FilePreview';
|
||||
|
||||
// Enhanced animation variants
|
||||
const containerVariants = {
|
||||
|
@ -38,6 +39,19 @@ const inputHoverVariants = {
|
|||
}
|
||||
};
|
||||
|
||||
// Add a new CSS class to hide the number input arrows
|
||||
const hiddenNumberInputArrows = `
|
||||
/* Hide number input spinners */
|
||||
input[type=number]::-webkit-inner-spin-button,
|
||||
input[type=number]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
`;
|
||||
|
||||
// File upload animation
|
||||
const fileUploadVariants = {
|
||||
initial: { scale: 1 },
|
||||
|
@ -57,13 +71,42 @@ interface TAPFormSectionProps {
|
|||
const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange }) => {
|
||||
const [roomBookingFile, setRoomBookingFile] = useState<File | null>(formData.room_booking);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const [showFilePreview, setShowFilePreview] = useState(false);
|
||||
const [filePreviewUrl, setFilePreviewUrl] = useState<string | null>(null);
|
||||
|
||||
// Handle room booking file upload
|
||||
// Add style tag for hidden arrows
|
||||
useEffect(() => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = hiddenNumberInputArrows;
|
||||
document.head.appendChild(style);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(style);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle room booking file upload with size limit
|
||||
const handleRoomBookingFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const file = e.target.files[0];
|
||||
|
||||
// Check file size - 1MB limit
|
||||
if (file.size > 1024 * 1024) {
|
||||
setFileError("Room booking file size must be under 1MB");
|
||||
return;
|
||||
}
|
||||
|
||||
setFileError(null);
|
||||
setRoomBookingFile(file);
|
||||
onDataChange({ room_booking: file });
|
||||
|
||||
// Create preview URL
|
||||
if (filePreviewUrl) {
|
||||
URL.revokeObjectURL(filePreviewUrl);
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
setFilePreviewUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -84,11 +127,40 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
|||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
const file = e.dataTransfer.files[0];
|
||||
|
||||
// Check file size - 1MB limit
|
||||
if (file.size > 1024 * 1024) {
|
||||
setFileError("Room booking file size must be under 1MB");
|
||||
return;
|
||||
}
|
||||
|
||||
setFileError(null);
|
||||
setRoomBookingFile(file);
|
||||
onDataChange({ room_booking: file });
|
||||
|
||||
// Create preview URL
|
||||
if (filePreviewUrl) {
|
||||
URL.revokeObjectURL(filePreviewUrl);
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
setFilePreviewUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to toggle file preview
|
||||
const toggleFilePreview = () => {
|
||||
setShowFilePreview(!showFilePreview);
|
||||
};
|
||||
|
||||
// Clean up object URL when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (filePreviewUrl) {
|
||||
URL.revokeObjectURL(filePreviewUrl);
|
||||
}
|
||||
};
|
||||
}, [filePreviewUrl]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="space-y-8"
|
||||
|
@ -105,10 +177,10 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
|||
<motion.div variants={itemVariants}>
|
||||
<CustomAlert
|
||||
type="info"
|
||||
title="Important Information"
|
||||
message="Please ensure you have ALL sections completed. If something is not available, let the coordinators know and be advised on how to proceed."
|
||||
title="CRITICAL INFORMATION"
|
||||
message="Failure to complete ALL sections with accurate information WILL result in event cancellation. This is non-negotiable. If information is not available, contact the event coordinator BEFORE submitting."
|
||||
className="mb-6"
|
||||
icon="heroicons:information-circle"
|
||||
icon="heroicons:exclamation-triangle"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
|
@ -127,7 +199,11 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
|||
type="number"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300 w-full pr-20"
|
||||
value={formData.expected_attendance || ''}
|
||||
onChange={(e) => onDataChange({ expected_attendance: parseInt(e.target.value) || 0 })}
|
||||
onChange={(e) => {
|
||||
// Allow any attendance number, no longer limiting to 500
|
||||
const attendance = parseInt(e.target.value) || 0;
|
||||
onDataChange({ expected_attendance: attendance });
|
||||
}}
|
||||
min="0"
|
||||
placeholder="Enter expected attendance"
|
||||
required
|
||||
|
@ -138,6 +214,30 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
|||
people
|
||||
</div>
|
||||
</div>
|
||||
{formData.expected_attendance > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-4 bg-success/20 p-3 rounded-lg"
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
Budget Calculator: $10 per person × {formData.expected_attendance} people
|
||||
</p>
|
||||
<p className="text-base font-bold mt-1">
|
||||
{formData.expected_attendance * 10 <= 5000 ? (
|
||||
`You cannot exceed spending past $${formData.expected_attendance * 10} dollars.`
|
||||
) : (
|
||||
`You cannot exceed spending past $5,000 dollars.`
|
||||
)}
|
||||
</p>
|
||||
{formData.expected_attendance * 10 > 5000 && (
|
||||
<p className="text-xs mt-1 text-warning">
|
||||
Budget cap reached. Maximum budget is $5,000 regardless of attendance.
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
className="mt-4 p-4 bg-base-300/30 rounded-lg text-xs text-gray-400"
|
||||
|
@ -152,7 +252,7 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
|||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Room booking confirmation */}
|
||||
{/* Room booking confirmation - Show file error if present */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
||||
|
@ -160,53 +260,110 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
|||
>
|
||||
<label className="label">
|
||||
<span className="label-text font-medium text-lg">Room Booking Confirmation</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
{formData.will_or_have_room_booking && <span className="label-text-alt text-error">*</span>}
|
||||
</label>
|
||||
|
||||
<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'
|
||||
}`}
|
||||
variants={fileUploadVariants}
|
||||
initial="initial"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => document.getElementById('room-booking-file')?.click()}
|
||||
>
|
||||
<input
|
||||
id="room-booking-file"
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleRoomBookingFileChange}
|
||||
accept=".pdf,.png,.jpg,.jpeg"
|
||||
/>
|
||||
|
||||
<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 }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
|
||||
{roomBookingFile ? (
|
||||
<>
|
||||
<p className="font-medium text-primary">File selected:</p>
|
||||
<p className="text-sm">{roomBookingFile.name}</p>
|
||||
<p className="text-xs text-gray-500">Click or drag to replace</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium">Drop your file here or click to browse</p>
|
||||
<p className="text-xs text-gray-500">Accepted formats: PDF, PNG, JPG</p>
|
||||
</>
|
||||
)}
|
||||
{fileError && (
|
||||
<div className="mt-2 mb-2">
|
||||
<CustomAlert
|
||||
type="error"
|
||||
title="File Error"
|
||||
message={fileError}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{formData.will_or_have_room_booking ? (
|
||||
<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'
|
||||
}`}
|
||||
variants={fileUploadVariants}
|
||||
initial="initial"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => document.getElementById('room-booking-file')?.click()}
|
||||
>
|
||||
<input
|
||||
id="room-booking-file"
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleRoomBookingFileChange}
|
||||
accept=".pdf,.png,.jpg,.jpeg"
|
||||
/>
|
||||
|
||||
<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 }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
|
||||
{roomBookingFile ? (
|
||||
<>
|
||||
<p className="font-medium text-primary">File selected:</p>
|
||||
<p className="text-sm">{roomBookingFile.name}</p>
|
||||
<p className="text-xs text-gray-500">Click or drag to replace (Max size: 1MB)</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium">Drop your file here or click to browse</p>
|
||||
<p className="text-xs text-gray-500">Accepted formats: PDF, PNG, JPG (Max size: 1MB)</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
className="mt-2 bg-base-300/30 rounded-lg p-4 text-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<p className="text-sm text-base-content/70">Room booking upload not required when no booking is needed.</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Preview File Button - Outside the upload area */}
|
||||
{formData.will_or_have_room_booking && roomBookingFile && (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={toggleFilePreview}
|
||||
>
|
||||
{showFilePreview ? 'Hide Preview' : 'Preview File'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Preview Component */}
|
||||
{showFilePreview && filePreviewUrl && roomBookingFile && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mt-4 p-4 bg-base-200 rounded-lg"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-medium">File Preview</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-circle"
|
||||
onClick={toggleFilePreview}
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<FilePreview url={filePreviewUrl} filename={roomBookingFile.name} />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Food/Drinks */}
|
||||
|
|
|
@ -256,7 +256,7 @@ try {
|
|||
<EventRequestModal client:load eventRequests={allEventRequests} />
|
||||
</div>
|
||||
|
||||
<script define:vars={{ ANIMATION_DELAYS }}>
|
||||
<script type="module" define:vars={{ ANIMATION_DELAYS }}>
|
||||
// Import the DataSyncService for client-side use
|
||||
import { DataSyncService } from "../../scripts/database/DataSyncService";
|
||||
import { Collections } from "../../schemas/pocketbase/schema";
|
||||
|
@ -269,6 +269,20 @@ try {
|
|||
}
|
||||
});
|
||||
|
||||
// Also force refresh when this tab is clicked in dashboard
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Find dashboard tab for event request management
|
||||
const eventRequestManagementTab = document.querySelector(
|
||||
'[data-section="eventRequestManagement"]'
|
||||
);
|
||||
if (eventRequestManagementTab) {
|
||||
eventRequestManagementTab.addEventListener("click", () => {
|
||||
// Dispatch custom event to refresh data when this tab is clicked
|
||||
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle authentication errors and initial data loading
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Initialize DataSyncService for client-side
|
||||
|
|
|
@ -708,7 +708,7 @@ const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request })
|
|||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<InvoiceTable invoiceData={invoiceData} />
|
||||
<InvoiceTable invoiceData={invoiceData} expectedAttendance={request.expected_attendance} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
@ -726,7 +726,7 @@ const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request })
|
|||
};
|
||||
|
||||
// Separate component for invoice table
|
||||
const InvoiceTable: React.FC<{ invoiceData: any }> = ({ invoiceData }) => {
|
||||
const InvoiceTable: React.FC<{ invoiceData: any, expectedAttendance?: number }> = ({ invoiceData, expectedAttendance }) => {
|
||||
// If no invoice data is provided, show a message
|
||||
if (!invoiceData) {
|
||||
return (
|
||||
|
@ -811,7 +811,7 @@ const InvoiceTable: React.FC<{ invoiceData: any }> = ({ invoiceData }) => {
|
|||
// 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?.price || 0);
|
||||
const price = parseFloat(item?.unit_price || item?.unitPrice || item?.price || 0);
|
||||
return sum + (quantity * price);
|
||||
}, 0);
|
||||
|
||||
|
@ -820,9 +820,26 @@ const InvoiceTable: React.FC<{ invoiceData: any }> = ({ invoiceData }) => {
|
|||
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>
|
||||
|
@ -840,7 +857,7 @@ const InvoiceTable: React.FC<{ invoiceData: any }> = ({ invoiceData }) => {
|
|||
: (item?.item || item?.description || item?.name || 'N/A');
|
||||
|
||||
const quantity = parseFloat(item?.quantity || 1);
|
||||
const unitPrice = parseFloat(item?.unit_price || item?.price || 0);
|
||||
const unitPrice = parseFloat(item?.unit_price || item?.unitPrice || item?.price || 0);
|
||||
const itemTotal = quantity * unitPrice;
|
||||
|
||||
return (
|
||||
|
@ -858,21 +875,17 @@ const InvoiceTable: React.FC<{ invoiceData: any }> = ({ invoiceData }) => {
|
|||
<td colSpan={3} className="text-right font-medium">Subtotal:</td>
|
||||
<td>${subtotal.toFixed(2)}</td>
|
||||
</tr>
|
||||
{tax > 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-right font-medium">Tax:</td>
|
||||
<td>${tax.toFixed(2)}</td>
|
||||
</tr>
|
||||
)}
|
||||
{tip > 0 && (
|
||||
<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-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">${total.toFixed(2)}</td>
|
||||
<td className={`font-bold ${isOverBudget ? 'text-error' : ''}`}>${total.toFixed(2)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
@ -888,9 +901,9 @@ const InvoiceTable: React.FC<{ invoiceData: any }> = ({ invoiceData }) => {
|
|||
return (
|
||||
<CustomAlert
|
||||
type="error"
|
||||
title="Processing Error"
|
||||
message="An unexpected error occurred while processing the invoice."
|
||||
icon="heroicons:exclamation-triangle"
|
||||
title="Error"
|
||||
message="An error occurred while processing the invoice data."
|
||||
icon="heroicons:exclamation-circle"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -1216,21 +1229,23 @@ const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }
|
|||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{
|
||||
!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
|
||||
|
@ -1464,11 +1479,6 @@ const EventRequestDetails = ({
|
|||
<label className="text-xs text-gray-400">Start Date & Time</label>
|
||||
<p className="text-white">{formatDate(request.start_date_time)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400">End Date & Time</label>
|
||||
<p className="text-white">{formatDate(request.end_date_time)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -33,12 +33,14 @@ interface EventRequestManagementTableProps {
|
|||
eventRequests: ExtendedEventRequest[];
|
||||
onRequestSelect: (request: ExtendedEventRequest) => void;
|
||||
onStatusChange: (id: string, status: "submitted" | "pending" | "completed" | "declined") => Promise<void>;
|
||||
isLoadingUserData?: boolean;
|
||||
}
|
||||
|
||||
const EventRequestManagementTable = ({
|
||||
eventRequests: initialEventRequests,
|
||||
onRequestSelect,
|
||||
onStatusChange
|
||||
onStatusChange,
|
||||
isLoadingUserData = false
|
||||
}: EventRequestManagementTableProps) => {
|
||||
const [eventRequests, setEventRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
|
||||
const [filteredRequests, setFilteredRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
|
||||
|
@ -69,13 +71,54 @@ const EventRequestManagementTable = ({
|
|||
true, // Force sync
|
||||
'', // No filter
|
||||
'-created',
|
||||
'requested_user'
|
||||
'requested_user' // This is correct but we need to ensure it's working in the DataSyncService
|
||||
);
|
||||
|
||||
// If we still have "Unknown" users, try to fetch them directly
|
||||
const requestsWithUsers = await Promise.all(
|
||||
updatedRequests.map(async (request) => {
|
||||
// If user data is missing, try to fetch it directly
|
||||
if (!request.expand?.requested_user && request.requested_user) {
|
||||
try {
|
||||
const userData = await dataSync.getItem(
|
||||
Collections.USERS,
|
||||
request.requested_user,
|
||||
true // Force sync the user data
|
||||
);
|
||||
|
||||
if (userData) {
|
||||
// TypeScript cast to access the properties
|
||||
const typedUserData = userData as unknown as {
|
||||
id: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
// Update the expand object with the user data
|
||||
return {
|
||||
...request,
|
||||
expand: {
|
||||
...(request.expand || {}),
|
||||
requested_user: {
|
||||
id: typedUserData.id,
|
||||
name: typedUserData.name || 'Unknown',
|
||||
email: typedUserData.email || 'Unknown'
|
||||
}
|
||||
}
|
||||
} as ExtendedEventRequest;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching user data for request ${request.id}:`, error);
|
||||
}
|
||||
}
|
||||
return request;
|
||||
})
|
||||
) as ExtendedEventRequest[];
|
||||
|
||||
// console.log(`Fetched ${updatedRequests.length} event requests`);
|
||||
|
||||
setEventRequests(updatedRequests);
|
||||
applyFilters(updatedRequests);
|
||||
setEventRequests(requestsWithUsers);
|
||||
applyFilters(requestsWithUsers);
|
||||
} catch (error) {
|
||||
// console.error('Error refreshing event requests:', error);
|
||||
toast.error('Failed to refresh event requests');
|
||||
|
@ -217,25 +260,32 @@ const EventRequestManagementTable = ({
|
|||
|
||||
// Helper to get user display info - always show email address
|
||||
const getUserDisplayInfo = (request: ExtendedEventRequest) => {
|
||||
// If we're still loading user data, show loading indicator
|
||||
if (isLoadingUserData) {
|
||||
return {
|
||||
name: request.expand?.requested_user?.name || 'Loading...',
|
||||
email: request.expand?.requested_user?.email || 'Loading...'
|
||||
};
|
||||
}
|
||||
|
||||
// First try to get from the expand object
|
||||
if (request.expand?.requested_user) {
|
||||
const user = request.expand.requested_user;
|
||||
// Show "Loading..." instead of "Unknown" while data is being fetched
|
||||
const name = user.name || 'Loading...';
|
||||
const name = user.name || 'Unknown';
|
||||
// Always show email regardless of emailVisibility
|
||||
const email = user.email || 'Loading...';
|
||||
const email = user.email || 'Unknown';
|
||||
return { name, email };
|
||||
}
|
||||
|
||||
// Then try the requested_user_expand
|
||||
if (request.requested_user_expand) {
|
||||
const name = request.requested_user_expand.name || 'Loading...';
|
||||
const email = request.requested_user_expand.email || 'Loading...';
|
||||
const name = request.requested_user_expand.name || 'Unknown';
|
||||
const email = request.requested_user_expand.email || 'Unknown';
|
||||
return { name, email };
|
||||
}
|
||||
|
||||
// Last fallback - don't use "Unknown" to avoid confusing users
|
||||
return { name: 'Loading...', email: 'Loading...' };
|
||||
// Last fallback
|
||||
return { name: 'Unknown', email: 'Unknown' };
|
||||
};
|
||||
|
||||
// Update openDetailModal to call the prop function
|
||||
|
|
|
@ -113,12 +113,14 @@ const TableWrapper: React.FC<{
|
|||
eventRequests: ExtendedEventRequest[];
|
||||
handleSelectRequest: (request: ExtendedEventRequest) => void;
|
||||
handleStatusChange: (id: string, status: "submitted" | "pending" | "completed" | "declined") => Promise<void>;
|
||||
}> = ({ eventRequests, handleSelectRequest, handleStatusChange }) => {
|
||||
isLoadingUserData?: boolean;
|
||||
}> = ({ eventRequests, handleSelectRequest, handleStatusChange, isLoadingUserData = false }) => {
|
||||
return (
|
||||
<EventRequestManagementTable
|
||||
eventRequests={eventRequests}
|
||||
onRequestSelect={handleSelectRequest}
|
||||
onStatusChange={handleStatusChange}
|
||||
isLoadingUserData={isLoadingUserData}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -189,29 +191,32 @@ const EventRequestModal: React.FC<EventRequestModalProps> = ({ eventRequests })
|
|||
}
|
||||
};
|
||||
|
||||
// Immediately load user data on mount
|
||||
// Immediately load user data on mount and when eventRequests change
|
||||
useEffect(() => {
|
||||
refreshUserDataAndUpdate(eventRequests);
|
||||
}, []);
|
||||
|
||||
// Effect to update local state when props change
|
||||
useEffect(() => {
|
||||
// Only update if we have new eventRequests from props
|
||||
if (eventRequests.length > 0) {
|
||||
// First update with what we have from props
|
||||
setLocalEventRequests(prevRequests => {
|
||||
// Only replace if we have different data
|
||||
if (eventRequests.length !== prevRequests.length) {
|
||||
return eventRequests;
|
||||
}
|
||||
return prevRequests;
|
||||
});
|
||||
|
||||
if (eventRequests && eventRequests.length > 0) {
|
||||
// First update with existing data from props
|
||||
setLocalEventRequests(eventRequests);
|
||||
// Then refresh user data
|
||||
refreshUserDataAndUpdate(eventRequests);
|
||||
}
|
||||
}, [eventRequests]);
|
||||
|
||||
// Ensure user data is loaded immediately when component mounts
|
||||
useEffect(() => {
|
||||
// Refresh user data immediately on mount
|
||||
refreshUserDataAndUpdate();
|
||||
|
||||
// Set up auto-refresh every 30 seconds
|
||||
const refreshInterval = setInterval(() => {
|
||||
refreshUserDataAndUpdate();
|
||||
}, 30000); // 30 seconds
|
||||
|
||||
// Clear interval on unmount
|
||||
return () => {
|
||||
clearInterval(refreshInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Set up event listeners for communication with the table component
|
||||
useEffect(() => {
|
||||
const handleSelectRequest = (event: CustomEvent) => {
|
||||
|
@ -252,7 +257,7 @@ const EventRequestModal: React.FC<EventRequestModalProps> = ({ eventRequests })
|
|||
return () => {
|
||||
document.removeEventListener('dashboardTabVisible', handleTabVisible);
|
||||
};
|
||||
}, [localEventRequests]);
|
||||
}, []);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
|
@ -338,6 +343,7 @@ const EventRequestModal: React.FC<EventRequestModalProps> = ({ eventRequests })
|
|||
eventRequests={localEventRequests}
|
||||
handleSelectRequest={handleSelectRequest}
|
||||
handleStatusChange={handleStatusChange}
|
||||
isLoadingUserData={isLoadingUserData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -37,6 +37,7 @@ export interface User extends BaseRecord {
|
|||
display_preferences?: string; // JSON string of display settings (theme, font size, etc.)
|
||||
accessibility_settings?: string; // JSON string of accessibility settings (color blind mode, reduced motion)
|
||||
resume?: string;
|
||||
signed_up?: boolean; // Whether the user has filled out the registration form.
|
||||
requested_email?: boolean; // Whether the user has requested an IEEE email address
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue