better event form

This commit is contained in:
chark1es 2025-02-24 16:16:55 -08:00
parent 8a2423eb72
commit 4065105bab
13 changed files with 2311 additions and 2083 deletions

View file

@ -4,551 +4,78 @@ import { Update } from "../../scripts/pocketbase/Update";
import { FileManager } from "../../scripts/pocketbase/FileManager"; import { FileManager } from "../../scripts/pocketbase/FileManager";
import { Get } from "../../scripts/pocketbase/Get"; import { Get } from "../../scripts/pocketbase/Get";
import { toast, Toaster } from "react-hot-toast"; import { toast, Toaster } from "react-hot-toast";
import EventRequestFormPreview from "./Officer_EventRequestForm/EventRequestFormPreview";
import EventRequestForm from "./Officer_EventRequestForm/EventRequestForm";
// Form sections
import PRSection from "./Officer_EventRequestForm/PRSection";
import EventDetailsSection from "./Officer_EventRequestForm/EventDetailsSection";
import TAPSection from "./Officer_EventRequestForm/TAPSection";
import ASFundingSection from "./Officer_EventRequestForm/ASFundingSection";
interface EventRequest {
id: string;
created: string;
event_name: string;
event_description: string;
location: string;
start_date: string;
end_date: string;
status: 'draft' | 'pending' | 'approved' | 'rejected';
has_food: boolean;
[key: string]: any; // For other fields we might need
}
interface ListResponse<T> {
page: number;
perPage: number;
totalItems: number;
totalPages: number;
items: T[];
}
const auth = Authentication.getInstance();
const update = Update.getInstance();
const fileManager = FileManager.getInstance();
const get = Get.getInstance();
// Get user's submitted event requests
let userEventRequests: ListResponse<EventRequest> = {
page: 1,
perPage: 50,
totalItems: 0,
totalPages: 0,
items: []
};
if (auth.isAuthenticated()) {
try {
userEventRequests = await get.getList<EventRequest>("event_request", 1, 50, `requested_user = "${auth.getUserId()}"`, "-created");
} catch (error) {
console.error("Failed to fetch event requests:", error);
}
}
--- ---
<div class="w-full max-w-4xl mx-auto p-6"> <div class="w-full max-w-6xl mx-auto py-8 px-4">
<toast client:load /> <div class="mb-8">
<h1 <h1 class="text-3xl font-bold text-white mb-2">Event Request Form</h1>
class="text-3xl font-bold mb-8 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent" <p class="text-gray-300 mb-4">
> Submit your event request at least 6 weeks before your event. After
Event Request Form submitting, please notify PR and/or Coordinators in the #-events Slack
</h1> channel.
</p>
<div class="bg-base-300/50 p-4 rounded-lg text-sm text-gray-300">
<p class="font-medium mb-2">This form includes sections for:</p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li>PR Materials (if needed)</li>
<li>Event Details</li>
<li>TAP Form Information</li>
<li>AS Funding (if needed)</li>
</ul>
<p class="mt-3">
You can switch between the form and preview tabs at any time. Your
progress is automatically saved.
</p>
</div>
</div>
<div class="tabs-container"> <div class="bg-base-200 rounded-lg shadow-xl overflow-hidden">
<!-- Tab Navigation --> <div class="tabs tabs-boxed bg-base-300 rounded-t-lg">
<div class="tabs tabs-boxed bg-base-200/50 backdrop-blur-sm p-1 rounded-lg mb-6"> <button id="form-tab" class="tab tab-active">Submit Request</button>
<input type="radio" name="form_tabs" id="new-request-tab" class="tab-toggle" checked /> <button id="preview-tab" class="tab">Preview Request</button>
<input type="radio" name="form_tabs" id="my-requests-tab" class="tab-toggle" /> </div>
<div class="w-full flex">
<label for="new-request-tab" class="tab flex-1 tab-lg transition-all duration-200">
<div class="flex items-center justify-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
New Request
</div>
</label>
<label for="my-requests-tab" class="tab flex-1 tab-lg transition-all duration-200">
<div class="flex items-center justify-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
<path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z" clip-rule="evenodd" />
</svg>
My Requests
{userEventRequests.items.length > 0 && (
<span class="badge badge-primary badge-sm">{userEventRequests.items.length}</span>
)}
</div>
</label>
</div>
<!-- Tab Content --> <div id="form-view" class="p-6">
<div class="tab-panels mt-6"> <EventRequestForm client:load />
<div class="tab-panel" id="new-request-panel"> </div>
<form id="eventRequestForm" class="space-y-8">
<div class="card bg-base-100/95 backdrop-blur-md shadow-lg">
<div class="card-body">
<h2 class="card-title text-xl">
Do you need graphics from our design team?
</h2>
<div class="space-y-4 mt-4">
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="needsGraphics"
value="yes"
class="radio radio-primary"
/>
<span class="label-text">Yes (Continue to PR Section)</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="needsGraphics"
value="no"
class="radio radio-primary"
/>
<span class="label-text">No (Skip to Event Details)</span>
</label>
</div>
</div>
</div>
<div id="prSection" class="hidden"> <div id="preview-view" class="p-6 hidden">
<PRSection client:load /> <EventRequestFormPreview client:load />
</div>
<div id="eventDetailsSection">
<EventDetailsSection client:load />
</div>
<div id="tapSection">
<TAPSection client:load>
<ASFundingSection client:load />
</TAPSection>
</div>
<div class="flex justify-end space-x-4 mt-8">
<button
type="button"
id="saveAsDraft"
class="btn btn-ghost hover:bg-base-200 gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
d="M7.707 10.293a1 1 0 10-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 11.586V6h5a2 2 0 012 2v7a2 2 0 01-2 2H4a2 2 0 01-2-2V8a2 2 0 012-2h5v5.586l-1.293-1.293zM9 4a1 1 0 012 0v2H9V4z"
></path>
</svg>
Save as Draft
</button>
<button
type="submit"
class="btn btn-primary gap-2 shadow-md hover:shadow-lg transition-all duration-300"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z"
></path>
</svg>
Submit Request
</button>
</div>
</form>
</div>
<div class="tab-panel hidden" id="my-requests-panel">
<div class="space-y-4">
{userEventRequests.items.length === 0 ? (
<div class="text-center py-8 text-base-content/70">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-12 w-12 mx-auto mb-4 opacity-50"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M5 3a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2V5a2 2 0 00-2-2H5zm0 2h10v7h-2l-1 2H8l-1-2H5V5z"
clip-rule="evenodd"
/>
</svg>
<p>No event requests found</p>
</div>
) : (
<div class="grid gap-4">
{userEventRequests.items.map((request) => (
<div class="card bg-base-100/95 backdrop-blur-md shadow-lg">
<div class="card-body">
<h3 class="card-title text-lg">
{request.event_name || "Untitled Event"}
{request.status === "draft" && (
<span class="badge badge-outline">Draft</span>
)}
{request.status === "pending" && (
<span class="badge badge-warning">Pending</span>
)}
{request.status === "approved" && (
<span class="badge badge-success">Approved</span>
)}
{request.status === "rejected" && (
<span class="badge badge-error">Rejected</span>
)}
</h3>
<p class="text-sm opacity-70">
Submitted: {new Date(request.created).toLocaleDateString()}
</p>
<div class="flex gap-2 mt-4">
<button class="btn btn-sm btn-outline" onclick=`editRequest('${request.id}')`>
Edit
</button>
<button class="btn btn-sm btn-ghost" onclick=`viewRequest('${request.id}')`>
View Details
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<style>
/* Hide radio inputs but keep them functional */
.tab-toggle {
position: absolute;
opacity: 0;
pointer-events: none;
}
/* Style tabs */
.tab {
@apply text-base-content/70 hover:text-base-content;
}
/* Active tab styles */
#new-request-tab:checked ~ .w-full label[for="new-request-tab"],
#my-requests-tab:checked ~ .w-full label[for="my-requests-tab"] {
@apply bg-base-100 text-primary tab-active shadow-sm;
}
/* Show/hide panels based on radio selection */
#new-request-tab:checked ~ .tab-panels #new-request-panel {
display: block;
}
#my-requests-tab:checked ~ .tab-panels #my-requests-panel {
display: block;
}
.tab-panel {
display: none;
}
.tab-panels {
min-height: 300px;
}
/* Smooth transitions */
.tab {
transition: all 0.2s ease-in-out;
}
.badge {
transition: all 0.2s ease-in-out;
}
</style>
<script> <script>
import { Authentication } from "../../scripts/pocketbase/Authentication"; // Tab switching logic
import { Update } from "../../scripts/pocketbase/Update"; const formTab = document.getElementById("form-tab");
import { FileManager } from "../../scripts/pocketbase/FileManager"; const previewTab = document.getElementById("preview-tab");
import { Get } from "../../scripts/pocketbase/Get"; const formView = document.getElementById("form-view");
import { toast } from "react-hot-toast"; const previewView = document.getElementById("preview-view");
// Add TypeScript interfaces formTab?.addEventListener("click", () => {
interface EventRequest { formTab.classList.add("tab-active");
id: string; previewTab?.classList.remove("tab-active");
created: string; formView?.classList.remove("hidden");
event_name: string; previewView?.classList.add("hidden");
event_description: string;
location: string;
start_date: string;
end_date: string;
status: 'draft' | 'pending' | 'approved' | 'rejected';
has_food: boolean;
[key: string]: any; // For other fields we might need
}
// Extend Window interface // Dispatch event to notify the preview component to update
declare global { const event = new CustomEvent("updatePreview");
interface Window { document.dispatchEvent(event);
editRequest: (requestId: string) => void;
viewRequest: (requestId: string) => void;
}
}
// Form visibility logic
const form = document.getElementById("eventRequestForm") as HTMLFormElement;
const prSection = document.getElementById("prSection");
const needsGraphicsRadios = document.getElementsByName("needsGraphics");
// Show/hide PR section based on radio selection
needsGraphicsRadios.forEach((radio) => {
radio.addEventListener("change", (e) => {
const target = e.target as HTMLInputElement;
if (target.value === "yes" && prSection) {
prSection.classList.remove("hidden");
} else if (prSection) {
prSection.classList.add("hidden");
}
});
}); });
// Form submission handler previewTab?.addEventListener("click", (e) => {
form?.addEventListener("submit", async (e) => { // Prevent default behavior to avoid form submission
e.preventDefault(); e.preventDefault();
try { previewTab.classList.add("tab-active");
const auth = Authentication.getInstance(); formTab?.classList.remove("tab-active");
const update = Update.getInstance(); previewView?.classList.remove("hidden");
const userId = auth.getUserId(); formView?.classList.add("hidden");
if (!userId) { // Dispatch event to notify the preview component to update
throw new Error('User not authenticated'); const event = new CustomEvent("updatePreview");
} document.dispatchEvent(event);
// Show loading toast
const loadingToastId = toast.loading('Submitting event request...');
// Get graphics need value
const needsGraphics = form.querySelector<HTMLInputElement>('input[name="needsGraphics"]:checked')?.value === "yes";
// Collect data from all sections
const eventData = {
requested_user: userId,
name: form.querySelector<HTMLInputElement>('[name="event_name"]')?.value,
description: form.querySelector<HTMLTextAreaElement>('[name="event_description"]')?.value,
location: form.querySelector<HTMLInputElement>('[name="location"]')?.value,
start_date_time: new Date(form.querySelector<HTMLInputElement>('[name="start_date_time"]')?.value || '').toISOString(),
end_date_time: new Date(form.querySelector<HTMLInputElement>('[name="end_date_time"]')?.value || '').toISOString(),
flyers_needed: needsGraphics,
flyer_type: Array.from(form.querySelectorAll<HTMLInputElement>('input[type="checkbox"][name="flyer_type[]"]:checked')).map(cb => cb.value),
other_flyer_type: form.querySelector<HTMLInputElement>('[name="other_flyer_type"]')?.value,
flyer_advertising_start_date: form.querySelector<HTMLInputElement>('[name="flyer_advertising_start_date"]')?.value,
flyer_additional_requests: form.querySelector<HTMLTextAreaElement>('[name="flyer_additional_requests"]')?.value,
photography_needed: form.querySelector<HTMLInputElement>('input[name="photography_needed"]:checked')?.value === 'true',
required_logos: Array.from(form.querySelectorAll<HTMLInputElement>('input[type="checkbox"][name="required_logos[]"]:checked')).map(cb => cb.value),
advertising_format: form.querySelector<HTMLSelectElement>('[name="advertising_format"]')?.value,
will_or_have_room_booking: form.querySelector<HTMLInputElement>('input[name="will_or_have_room_booking"]:checked')?.value === 'true',
expected_attendance: parseInt(form.querySelector<HTMLInputElement>('[name="expected_attendance"]')?.value || '0'),
as_funding_required: form.querySelector<HTMLInputElement>('input[name="as_funding"]:checked')?.value === 'true',
food_drinks_being_served: form.querySelector<HTMLInputElement>('input[name="food_drinks"]:checked')?.value === 'true',
status: 'pending',
draft: false,
// Add itemized items if AS funding is required
itemized_items: form.querySelector<HTMLInputElement>('input[name="as_funding"]:checked')?.value === 'true'
? JSON.parse(form.querySelector<HTMLInputElement>('[name="itemized_items"]')?.value || '[]')
: undefined,
// Add itemized invoice data
itemized_invoice: form.querySelector<HTMLInputElement>('input[name="as_funding"]:checked')?.value === 'true'
? JSON.parse(form.querySelector<HTMLInputElement>('[name="itemized_invoice"]')?.value || '{}')
: undefined
};
// Create the record
const record = await update.create("event_request", eventData);
// Handle file uploads
const fileManager = FileManager.getInstance();
const fileFields = {
room_booking: form.querySelector<HTMLInputElement>('input[type="file"][name="room_booking"]')?.files?.[0],
itemized_invoice: form.querySelector<HTMLInputElement>('input[type="file"][name="itemized_invoice"]')?.files?.[0],
invoice: form.querySelector<HTMLInputElement>('input[type="file"][name="invoice"]')?.files?.[0],
other_logos: Array.from(form.querySelector<HTMLInputElement>('input[type="file"][name="other_logos"]')?.files || [])
};
// Upload files one by one
for (const [field, files] of Object.entries(fileFields)) {
if (!files) continue;
if (Array.isArray(files)) {
// Handle multiple files (other_logos)
const validFiles = files.filter((f): f is File => f instanceof File);
if (validFiles.length > 0) {
await fileManager.uploadFiles(
"event_request",
record.id,
field,
validFiles
);
}
} else {
// Handle single file
await fileManager.uploadFile(
"event_request",
record.id,
field,
files
);
}
}
// Dismiss loading toast and show success
toast.dismiss(loadingToastId);
toast.success('Event request submitted successfully!');
// Reset form
form.reset();
} catch (error) {
console.error("Error submitting form:", error);
toast.error('Failed to submit event request. Please try again.');
}
}); });
// Save as draft handler
document
.getElementById("saveAsDraft")
?.addEventListener("click", async () => {
try {
const formData = new FormData(form);
const data: Record<string, any> = {};
// Convert FormData to a proper object with correct types
formData.forEach((value, key) => {
if (value instanceof File) {
// Skip file fields as they'll be handled separately
return;
}
data[key] = value;
});
data.status = "draft";
const auth = Authentication.getInstance();
const update = Update.getInstance();
const userId = auth.getUserId();
if (userId) {
data.requested_user = userId;
}
await update.updateFields("event_request", data.id || "", data);
toast.success('Draft saved successfully!');
} catch (error) {
console.error("Error saving draft:", error);
toast.error('Error saving draft. Please try again.');
}
});
// Remove the old tab switching logic and add new event listeners for the radio buttons
const tabToggles = document.querySelectorAll('.tab-toggle');
const tabPanels = document.querySelectorAll('.tab-panel');
tabToggles.forEach(toggle => {
toggle.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
const panelId = target.id.replace('-tab', '-panel');
// Hide all panels
tabPanels.forEach(panel => {
panel.classList.add('hidden');
});
// Show selected panel
const selectedPanel = document.getElementById(panelId);
if (selectedPanel) {
selectedPanel.classList.remove('hidden');
}
});
});
// Edit request handler
window.editRequest = (requestId: string) => {
// Load the request data into the form
const get = Get.getInstance();
get.getOne<EventRequest>("event_request", requestId)
.then((request: EventRequest) => {
// Populate form fields with request data
const form = document.getElementById('eventRequestForm');
if (form) {
for (const [key, value] of Object.entries(request)) {
const input = form.querySelector(`[name="${key}"]`) as HTMLInputElement | null;
if (input) {
input.value = value;
}
}
// Switch to the form tab
const newRequestTab = document.querySelector('[data-tab="new-request"]') as HTMLElement;
if (newRequestTab) {
newRequestTab.click();
}
}
})
.catch((error: Error) => {
console.error("Failed to load request:", error);
toast.error("Failed to load request. Please try again.");
});
};
// View request handler
window.viewRequest = (requestId: string) => {
// Open a modal with request details
const get = Get.getInstance();
get.getOne<EventRequest>("event_request", requestId)
.then((request: EventRequest) => {
const modal = document.createElement('dialog');
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-box">
<h3 class="font-bold text-lg">${request.event_name || "Untitled Event"}</h3>
<div class="py-4 space-y-2">
<p><strong>Status:</strong> ${request.status}</p>
<p><strong>Description:</strong> ${request.event_description || "No description"}</p>
<p><strong>Location:</strong> ${request.location || "No location"}</p>
<p><strong>Start Date:</strong> ${new Date(request.start_date).toLocaleString()}</p>
<p><strong>End Date:</strong> ${new Date(request.end_date).toLocaleString()}</p>
${request.has_food ? '<p><strong>Food:</strong> Yes</p>' : ''}
</div>
<div class="modal-action">
<form method="dialog">
<button class="btn">Close</button>
</form>
</div>
</div>
`;
document.body.appendChild(modal);
modal.showModal();
})
.catch((error: Error) => {
console.error("Failed to load request:", error);
toast.error("Failed to load request details. Please try again.");
});
};
</script> </script>

View file

@ -1,475 +1,190 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion } from 'framer-motion';
import { toast } from 'react-hot-toast'; import type { EventRequestFormData } from './EventRequestForm';
import InfoCard from './InfoCard'; import InvoiceBuilder from './InvoiceBuilder';
import Tooltip from './Tooltip'; import type { InvoiceData } from './InvoiceBuilder';
import { tooltips, infoNotes } from './tooltips';
import { Icon } from '@iconify/react';
export interface InvoiceItem { // Animation variants
quantity: number; const itemVariants = {
item_name: string; hidden: { opacity: 0, y: 20 },
unit_cost: number; visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
interface ASFundingSectionProps {
formData: EventRequestFormData;
onDataChange: (data: Partial<EventRequestFormData>) => void;
} }
interface InvoiceData { const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataChange }) => {
items: InvoiceItem[]; const [invoiceFile, setInvoiceFile] = useState<File | null>(formData.invoice);
tax: number; const [invoiceFiles, setInvoiceFiles] = useState<File[]>(formData.invoice_files || []);
tip: number;
total: number;
vendor: string;
}
export interface ASFundingSectionProps { // Handle single invoice file upload (for backward compatibility)
onDataChange?: (data: any) => void; const handleInvoiceFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onItemizedItemsUpdate?: (items: InvoiceItem[]) => void; if (e.target.files && e.target.files.length > 0) {
} const file = e.target.files[0];
setInvoiceFile(file);
const ASFundingSection: React.FC<ASFundingSectionProps> = ({ onDataChange, onItemizedItemsUpdate }) => { onDataChange({ invoice: file });
const [invoiceData, setInvoiceData] = useState<InvoiceData>({
items: [{ quantity: 1, item_name: '', unit_cost: 0 }],
tax: 0,
tip: 0,
total: 0,
vendor: ''
});
const handleItemChange = (index: number, field: keyof InvoiceItem, value: string | number) => {
const newItems = [...invoiceData.items];
newItems[index] = { ...newItems[index], [field]: value };
// Calculate new total
const itemsTotal = newItems.reduce((sum, item) => sum + (item.quantity * item.unit_cost), 0);
const newTotal = itemsTotal + (invoiceData.tax || 0) + (invoiceData.tip || 0);
const newInvoiceData = {
...invoiceData,
items: newItems,
total: newTotal
};
setInvoiceData(newInvoiceData);
// Send the entire invoice data object
onDataChange?.({
itemized_invoice: newInvoiceData,
total_amount: newTotal
});
// Update parent with itemized items and invoice data
onItemizedItemsUpdate?.(newItems);
document.querySelector<HTMLInputElement>('input[name="itemized_invoice"]')?.setAttribute('value', JSON.stringify(newInvoiceData));
};
const addItem = () => {
const newItems = [...invoiceData.items, { quantity: 1, item_name: '', unit_cost: 0 }];
const itemsTotal = newItems.reduce((sum, item) => sum + (item.quantity * item.unit_cost), 0);
const newTotal = itemsTotal + (invoiceData.tax || 0) + (invoiceData.tip || 0);
const newInvoiceData = {
...invoiceData,
items: newItems,
total: newTotal
};
setInvoiceData(newInvoiceData);
onDataChange?.({
itemized_invoice: newInvoiceData,
total_amount: newTotal
});
onItemizedItemsUpdate?.(newItems);
document.querySelector<HTMLInputElement>('input[name="itemized_invoice"]')?.setAttribute('value', JSON.stringify(newInvoiceData));
toast('New item added');
};
const removeItem = (index: number) => {
if (invoiceData.items.length > 1) {
const newItems = invoiceData.items.filter((_, i) => i !== index);
const itemsTotal = newItems.reduce((sum, item) => sum + (item.quantity * item.unit_cost), 0);
const newTotal = itemsTotal + (invoiceData.tax || 0) + (invoiceData.tip || 0);
const newInvoiceData = {
...invoiceData,
items: newItems,
total: newTotal
};
setInvoiceData(newInvoiceData);
onDataChange?.({
itemized_invoice: newInvoiceData,
total_amount: newTotal
});
onItemizedItemsUpdate?.(newItems);
document.querySelector<HTMLInputElement>('input[name="itemized_invoice"]')?.setAttribute('value', JSON.stringify(newInvoiceData));
toast('Item removed');
} }
}; };
const handleExtraChange = (field: 'tax' | 'tip' | 'vendor', value: string | number) => { // Handle multiple invoice files upload
const numValue = field !== 'vendor' ? Number(value) : value; const handleMultipleInvoiceFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const itemsTotal = invoiceData.items.reduce((sum, item) => sum + (item.quantity * item.unit_cost), 0); if (e.target.files && e.target.files.length > 0) {
const files = Array.from(e.target.files);
setInvoiceFiles(prevFiles => [...prevFiles, ...files]);
onDataChange({ invoice_files: [...formData.invoice_files, ...files] });
const newTotal = field === 'tax' ? // Also set the first file as the main invoice file for backward compatibility
itemsTotal + Number(value) + (invoiceData.tip || 0) : if (files.length > 0 && !formData.invoice) {
field === 'tip' ? setInvoiceFile(files[0]);
itemsTotal + (invoiceData.tax || 0) + Number(value) : onDataChange({ invoice: files[0] });
itemsTotal + (invoiceData.tax || 0) + (invoiceData.tip || 0); }
}
const newInvoiceData = {
...invoiceData,
[field]: numValue,
total: field !== 'vendor' ? newTotal : invoiceData.total
};
setInvoiceData(newInvoiceData);
onDataChange?.({
itemized_invoice: newInvoiceData,
total_amount: newTotal
});
document.querySelector<HTMLInputElement>('input[name="itemized_invoice"]')?.setAttribute('value', JSON.stringify(newInvoiceData));
}; };
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => { // Remove an invoice file
const file = e.target.files?.[0]; const handleRemoveInvoiceFile = (index: number) => {
if (file) { const updatedFiles = [...invoiceFiles];
onDataChange?.({ invoice: file }); updatedFiles.splice(index, 1);
toast('Invoice file uploaded'); setInvoiceFiles(updatedFiles);
onDataChange({ invoice_files: updatedFiles });
// Update the main invoice file if needed
if (index === 0 && updatedFiles.length > 0) {
setInvoiceFile(updatedFiles[0]);
onDataChange({ invoice: updatedFiles[0] });
} else if (updatedFiles.length === 0) {
setInvoiceFile(null);
onDataChange({ invoice: null });
} }
}; };
// Handle invoice data change
const handleInvoiceDataChange = (invoiceData: InvoiceData) => {
// Update the invoiceData in the form
onDataChange({ invoiceData });
// For backward compatibility, create a properly formatted JSON string
const jsonFormat = {
items: invoiceData.items.map(item => ({
item: item.description,
quantity: item.quantity,
unit_price: item.unitPrice
})),
tax: invoiceData.taxAmount,
tip: invoiceData.tipAmount,
total: invoiceData.total,
vendor: invoiceData.vendor
};
// For backward compatibility, still update the itemized_invoice field
// but with a more structured format that's easier to parse if needed
const itemizedText = JSON.stringify(jsonFormat, null, 2);
// Update the itemized_invoice field for backward compatibility
onDataChange({ itemized_invoice: itemizedText });
};
return ( return (
<motion.div <div className="space-y-6">
initial={{ opacity: 0, y: 20 }} <h2 className="text-2xl font-bold mb-4 text-primary">AS Funding Information</h2>
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="card bg-base-100/95 backdrop-blur-md shadow-lg hover:shadow-xl transition-all duration-300"
>
<div className="card-body">
<motion.h2
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="card-title text-xl mb-6 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent flex items-center gap-2"
>
<Icon icon="mdi:cash" className="h-6 w-6" />
AS Funding Details
</motion.h2>
<div className="space-y-8"> <div className="bg-base-300/50 p-4 rounded-lg mb-6">
<InfoCard <div className="flex items-start gap-2">
title={infoNotes.asFunding.title} <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-warning flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
items={infoNotes.asFunding.items} <path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
type="warning" </svg>
className="mb-6" <p className="text-sm">
/> Please make sure the restaurant is a valid AS Funding food vendor! An invoice can be an unofficial receipt. Just make sure that the restaurant name and location, desired pickup or delivery date and time, all the items ordered plus their prices, discount/fees/tax/tip, and total are on the invoice! We don't recommend paying out of pocket because reimbursements can be a hassle when you're not a Principal Member.
</p>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="form-control w-full"
>
<div className="flex items-center justify-between mb-2">
<label className="label">
<span className="label-text font-medium text-lg flex items-center gap-2">
<Icon icon="mdi:store" className="h-5 w-5 text-primary" />
Vendor Information
</span>
</label>
<Tooltip
title={tooltips.vendor.title}
description={tooltips.vendor.description}
position="left"
>
<div className="badge badge-primary badge-outline p-3 cursor-help">
<Icon icon="mdi:information-outline" className="h-4 w-4" />
</div>
</Tooltip>
</div>
<div className="relative group">
<input
type="text"
placeholder="Enter vendor name and location"
className="input input-bordered w-full pl-12 transition-all duration-300 focus:ring-2 focus:ring-primary/20"
value={invoiceData.vendor}
onChange={(e) => handleExtraChange('vendor', e.target.value)}
required
/>
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-gray-400 group-hover:text-primary transition-colors duration-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="form-control w-full"
>
<div className="flex items-center justify-between mb-2">
<label className="label">
<span className="label-text font-medium text-lg flex items-center gap-2">
<Icon icon="mdi:file-document-outline" className="h-5 w-5 text-primary" />
Itemized Invoice
</span>
</label>
<Tooltip
title={tooltips.invoice.title}
description={tooltips.invoice.description}
position="left"
>
<div className="badge badge-primary badge-outline p-3 cursor-help">
<Icon icon="mdi:information-outline" className="h-4 w-4" />
</div>
</Tooltip>
</div>
<AnimatePresence mode="popLayout">
<div className="space-y-4">
{invoiceData.items.map((item, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.2 }}
className="flex gap-4 items-end bg-base-200/50 p-4 rounded-lg group hover:bg-base-200 transition-colors duration-300"
>
<div className="form-control flex-1">
<label className="label">
<span className="label-text">Quantity</span>
</label>
<input
type="number"
min="1"
className="input input-bordered w-full transition-all duration-300 focus:ring-2 focus:ring-primary/20"
value={item.quantity || ''}
onChange={(e) => handleItemChange(index, 'quantity', Number(e.target.value))}
required
/>
</div>
<div className="form-control flex-[3]">
<label className="label">
<span className="label-text">Item Name</span>
</label>
<input
type="text"
className="input input-bordered w-full transition-all duration-300 focus:ring-2 focus:ring-primary/20"
value={item.item_name}
onChange={(e) => handleItemChange(index, 'item_name', e.target.value)}
required
/>
</div>
<div className="form-control flex-1">
<label className="label">
<span className="label-text">Unit Cost ($)</span>
</label>
<div className="relative">
<input
type="number"
min="0"
step="0.01"
className="input input-bordered w-full pl-8 transition-all duration-300 focus:ring-2 focus:ring-primary/20"
value={item.unit_cost || ''}
onChange={(e) => handleItemChange(index, 'unit_cost', Number(e.target.value))}
required
/>
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span className="text-gray-400">$</span>
</div>
</div>
</div>
<motion.button
type="button"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="btn btn-ghost btn-square opacity-0 group-hover:opacity-100 transition-opacity duration-300"
onClick={() => removeItem(index)}
disabled={invoiceData.items.length === 1}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</motion.button>
</motion.div>
))}
</div>
</AnimatePresence>
<motion.button
type="button"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className="btn btn-ghost mt-4 w-full group hover:bg-primary/10"
onClick={addItem}
>
<Icon icon="mdi:plus" className="h-6 w-6 mr-2 group-hover:text-primary transition-colors duration-300" />
Add Item
</motion.button>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="grid grid-cols-2 gap-4"
>
<div className="form-control">
<div className="flex items-center justify-between mb-2">
<label className="label">
<span className="label-text font-medium flex items-center gap-2">
<Icon icon="mdi:percent" className="h-5 w-5 text-primary" />
Tax ($)
</span>
</label>
<Tooltip
title={tooltips.tax.title}
description={tooltips.tax.description}
position="top"
>
<div className="badge badge-primary badge-outline p-3 cursor-help">
<Icon icon="mdi:information-outline" className="h-4 w-4" />
</div>
</Tooltip>
</div>
<div className="relative group">
<input
type="number"
min="0"
step="0.01"
className="input input-bordered pl-10 w-full transition-all duration-300 focus:ring-2 focus:ring-primary/20"
value={invoiceData.tax || ''}
onChange={(e) => handleExtraChange('tax', Number(e.target.value))}
required
/>
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span className="text-gray-400 group-hover:text-primary transition-colors duration-300">$</span>
</div>
</div>
</div>
<div className="form-control">
<div className="flex items-center justify-between mb-2">
<label className="label">
<span className="label-text font-medium flex items-center gap-2">
<Icon icon="mdi:hand-coin" className="h-5 w-5 text-primary" />
Tip ($)
</span>
</label>
<Tooltip
title={tooltips.tip.title}
description={tooltips.tip.description}
position="top"
>
<div className="badge badge-primary badge-outline p-3 cursor-help">
<Icon icon="mdi:information-outline" className="h-4 w-4" />
</div>
</Tooltip>
</div>
<div className="relative group">
<input
type="number"
min="0"
step="0.01"
className="input input-bordered pl-10 w-full transition-all duration-300 focus:ring-2 focus:ring-primary/20"
value={invoiceData.tip || ''}
onChange={(e) => handleExtraChange('tip', Number(e.target.value))}
required
/>
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span className="text-gray-400 group-hover:text-primary transition-colors duration-300">$</span>
</div>
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }}
className="form-control"
>
<div className="flex items-center justify-between mb-2">
<label className="label">
<span className="label-text font-medium text-lg flex items-center gap-2">
<Icon icon="mdi:calculator" className="h-5 w-5 text-primary" />
Total Amount
</span>
</label>
<Tooltip
title={tooltips.total.title}
description={tooltips.total.description}
position="left"
>
<div className="badge badge-primary badge-outline p-3 cursor-help">
<Icon icon="mdi:information-outline" className="h-4 w-4" />
</div>
</Tooltip>
</div>
<div className="relative group">
<input
type="number"
className="input input-bordered pl-10 w-full font-bold bg-base-200/50 transition-all duration-300"
value={invoiceData.total.toFixed(2)}
disabled
/>
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span className="text-gray-400 group-hover:text-primary transition-colors duration-300">$</span>
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8 }}
className="form-control w-full"
>
<div className="flex items-center justify-between mb-2">
<label className="label">
<span className="label-text font-medium text-lg flex items-center gap-2">
<Icon icon="mdi:cloud-upload" className="h-5 w-5 text-primary" />
Upload Invoice
</span>
</label>
<Tooltip
title={tooltips.invoice.title}
description={tooltips.invoice.description}
position="left"
>
<div className="badge badge-primary badge-outline p-3 cursor-help">
<Icon icon="mdi:information-outline" className="h-4 w-4" />
</div>
</Tooltip>
</div>
<InfoCard
title={infoNotes.invoice.title}
items={infoNotes.invoice.items}
type="info"
className="mb-4"
/>
<div className="relative group">
<input
type="file"
name="invoice"
className="file-input file-input-bordered file-input-primary w-full"
onChange={handleFileUpload}
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"
required
/>
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-gray-400 group-hover:text-primary transition-colors duration-300" 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>
</div>
</div>
</motion.div>
</div> </div>
</div> </div>
</motion.div>
{/* Invoice Builder Instructions */}
<motion.div variants={itemVariants} className="bg-base-300/50 p-4 rounded-lg mb-6">
<h3 className="font-bold text-lg mb-2">How to Use the Invoice Builder</h3>
<ol className="list-decimal list-inside space-y-2 text-sm">
<li>Enter the vendor/restaurant name in the field provided.</li>
<li>Add each item from your invoice by filling in the description, quantity, and unit price, then click "Add Item".</li>
<li>The subtotal, tax, and tip will be calculated automatically based on the tax rate and tip percentage.</li>
<li>You can adjust the tax rate (default is 7.75% for San Diego) and tip percentage as needed.</li>
<li>Remove items by clicking the "X" button next to each item.</li>
<li>Upload your actual invoice file (receipt, screenshot, etc.) using the file upload below.</li>
</ol>
<p className="text-sm mt-3 text-warning">Note: The invoice builder helps you itemize your expenses for AS funding. You must still upload the actual invoice file.</p>
</motion.div>
{/* Invoice Builder */}
<InvoiceBuilder
invoiceData={formData.invoiceData}
onChange={handleInvoiceDataChange}
/>
{/* Invoice file upload */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">
Upload your invoice files (receipts, screenshots, etc.)
</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="file"
className="file-input file-input-bordered w-full file-input-primary hover:file-input-ghost transition-all duration-300"
onChange={handleMultipleInvoiceFilesChange}
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"
multiple
required={invoiceFiles.length === 0}
/>
{invoiceFiles.length > 0 && (
<div className="mt-4">
<p className="text-sm font-medium mb-2">Uploaded files:</p>
<div className="space-y-2">
{invoiceFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-base-300/30 p-2 rounded">
<span className="text-sm truncate max-w-[80%]">{file.name}</span>
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => handleRemoveInvoiceFile(index)}
>
<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>
))}
</div>
</div>
)}
<p className="text-xs text-gray-400 mt-2">
Official food invoices will be required 2 weeks before the start of your event. Please use the following naming format: EventName_OrderLocation_DateOfEvent (i.e. QPWorkathon#1_PapaJohns_01/06/2025)
</p>
</motion.div>
<motion.div
variants={itemVariants}
className="alert alert-warning"
>
<div>
<h3 className="font-bold">Important Note</h3>
<div className="text-sm">
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.
</div>
</div>
</motion.div>
</div>
); );
}; };

View file

@ -1,147 +1,144 @@
import React from 'react'; import React from 'react';
import { motion } from 'framer-motion';
import type { EventRequestFormData } from './EventRequestForm';
// Animation variants
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
interface EventDetailsSectionProps { interface EventDetailsSectionProps {
onDataChange?: (data: any) => void; formData: EventRequestFormData;
onDataChange: (data: Partial<EventRequestFormData>) => void;
} }
const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ onDataChange }) => { const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onDataChange }) => {
return ( return (
<div className="card bg-base-100/95 backdrop-blur-md shadow-lg"> <div className="space-y-6">
<div className="card-body"> <h2 className="text-2xl font-bold mb-4 text-primary">Event Details</h2>
<h2 className="card-title text-xl mb-6 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Event Details
</h2>
<div className="space-y-6 mt-4"> <div className="bg-base-300/50 p-4 rounded-lg mb-6">
<div className="form-control w-full"> <p className="text-sm">
<label className="label"> Please remember to ping @Coordinators in #-events on Slack once you've submitted this form so that they can fill out a TAP form for you.
<span className="label-text font-medium flex items-center gap-2"> </p>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Event Name
</span>
</label>
<input
type="text"
name="event_name"
className="input input-bordered w-full"
onChange={(e) => onDataChange?.({ name: e.target.value })}
required
/>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text font-medium flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Event Description
</span>
</label>
<textarea
name="event_description"
className="textarea textarea-bordered h-32"
onChange={(e) => onDataChange?.({ description: e.target.value })}
required
/>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text font-medium flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Event Start Date
</span>
</label>
<input
type="datetime-local"
name="start_date_time"
className="input input-bordered w-full"
onChange={(e) => onDataChange?.({ start_date_time: e.target.value })}
required
/>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text font-medium flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Event End Date
</span>
</label>
<input
type="datetime-local"
name="end_date_time"
className="input input-bordered w-full"
onChange={(e) => onDataChange?.({ end_date_time: e.target.value })}
required
/>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text font-medium flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Event Location
</span>
</label>
<input
type="text"
name="location"
className="input input-bordered w-full"
onChange={(e) => onDataChange?.({ location: e.target.value })}
required
/>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text font-medium flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
Room Booking Status
</span>
</label>
<div className="flex gap-4">
<label className="label cursor-pointer justify-start gap-3 hover:bg-base-200/50 p-4 rounded-lg transition-colors duration-300">
<input
type="radio"
name="will_or_have_room_booking"
value="true"
className="radio radio-primary"
onChange={(e) => onDataChange?.({ will_or_have_room_booking: e.target.value === 'true' })}
required
/>
<span className="label-text">Yes</span>
</label>
<label className="label cursor-pointer justify-start gap-3 hover:bg-base-200/50 p-4 rounded-lg transition-colors duration-300">
<input
type="radio"
name="will_or_have_room_booking"
value="false"
className="radio radio-primary"
onChange={(e) => onDataChange?.({ will_or_have_room_booking: e.target.value === 'true' })}
required
/>
<span className="label-text">No</span>
</label>
</div>
</div>
</div>
</div> </div>
{/* Event Name */}
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
<label className="label">
<span className="label-text font-medium text-lg">Event Name</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
value={formData.name}
onChange={(e) => onDataChange({ name: e.target.value })}
placeholder="Enter event name"
required
/>
</motion.div>
{/* Event Description */}
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
<label className="label">
<span className="label-text font-medium text-lg">Event Description</span>
<span className="label-text-alt text-error">*</span>
</label>
<textarea
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px] mt-2"
value={formData.event_description}
onChange={(e) => onDataChange({ event_description: e.target.value })}
placeholder="Provide a detailed description of your event"
rows={4}
required
/>
</motion.div>
{/* Event Start Date */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Event Start Date & Time</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="datetime-local"
className="input input-bordered focus:input-primary transition-all duration-300"
value={formData.start_date_time}
onChange={(e) => onDataChange({ start_date_time: e.target.value })}
required
/>
</motion.div>
{/* Event End Date */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Event End Date & Time</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="datetime-local"
className="input input-bordered focus:input-primary transition-all duration-300"
value={formData.end_date_time}
onChange={(e) => onDataChange({ end_date_time: e.target.value })}
required
/>
</motion.div>
{/* Event Location */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Event Location</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
className="input input-bordered focus:input-primary transition-all duration-300"
value={formData.location}
onChange={(e) => onDataChange({ location: e.target.value })}
placeholder="Enter event location"
required
/>
</motion.div>
{/* Room Booking */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Do you/will you have a room booking for this event?</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="radio radio-primary"
checked={formData.will_or_have_room_booking === true}
onChange={() => onDataChange({ will_or_have_room_booking: true })}
required
/>
<span>Yes</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="radio radio-primary"
checked={formData.will_or_have_room_booking === false}
onChange={() => onDataChange({ will_or_have_room_booking: false })}
required
/>
<span>No</span>
</label>
</div>
</motion.div>
</div> </div>
); );
}; };

View file

@ -0,0 +1,813 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import toast, { Toaster } from 'react-hot-toast';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Update } from '../../../scripts/pocketbase/Update';
import { FileManager } from '../../../scripts/pocketbase/FileManager';
import { Get } from '../../../scripts/pocketbase/Get';
// Form sections
import PRSection from './PRSection';
import EventDetailsSection from './EventDetailsSection';
import TAPFormSection from './TAPFormSection';
import ASFundingSection from './ASFundingSection';
import EventRequestFormPreview from './EventRequestFormPreview';
import InvoiceBuilder from './InvoiceBuilder';
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
export interface EventRequestFormData {
name: string;
location: string;
start_date_time: string;
end_date_time: string;
event_description: string;
flyers_needed: boolean;
flyer_type: string[];
other_flyer_type: string;
flyer_advertising_start_date: string;
flyer_additional_requests: string;
photography_needed: boolean;
required_logos: string[];
other_logos: File[];
advertising_format: string;
will_or_have_room_booking: boolean;
expected_attendance: number;
room_booking: File | null;
as_funding_required: boolean;
food_drinks_being_served: boolean;
itemized_invoice: string;
invoice: File | null;
invoice_files: File[]; // Support for multiple invoice files
needs_graphics: boolean | null;
needs_as_funding: boolean;
invoiceData: InvoiceData;
formReviewed: boolean; // New field to track if the form has been reviewed
}
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,
taxRate: 7.75, // Default tax rate for San Diego
taxAmount: 0,
tipPercentage: 15, // Default tip percentage
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>) => {
setFormData(prevData => ({
...prevData,
...sectionData
}));
};
// 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);
// Show initial submitting toast
const submittingToast = toast.loading('Preparing to submit your event request...');
try {
const auth = Authentication.getInstance();
const update = Update.getInstance();
const fileManager = FileManager.getInstance();
if (!auth.isAuthenticated()) {
toast.error('You must be logged in to submit an event request', { id: submittingToast });
throw new Error('You must be logged in to submit an event request');
}
// Create the event request record
const userId = auth.getUserId();
if (!userId) {
toast.error('User ID not found', { id: submittingToast });
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: new Date(formData.end_date_time).toISOString(),
event_description: formData.event_description,
flyers_needed: formData.flyers_needed,
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,
photography_needed: formData.photography_needed,
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,
as_funding_required: formData.as_funding_required,
food_drinks_being_served: formData.food_drinks_being_served,
// Store the itemized_invoice as a string for backward compatibility
itemized_invoice: formData.itemized_invoice,
// 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,
total: formData.invoiceData.total,
vendor: formData.invoiceData.vendor
},
};
toast.loading('Creating event request record...', { id: submittingToast });
try {
// Create the record
const record = await update.create('event_request', submissionData);
// Upload files if they exist
if (formData.other_logos.length > 0) {
toast.loading('Uploading logo files...', { id: submittingToast });
await fileManager.uploadFiles('event_request', record.id, 'other_logos', formData.other_logos);
}
if (formData.room_booking) {
toast.loading('Uploading room booking confirmation...', { id: submittingToast });
await fileManager.uploadFile('event_request', record.id, 'room_booking', formData.room_booking);
}
// Upload the main invoice file (for backward compatibility)
if (formData.invoice) {
toast.loading('Uploading invoice file...', { id: submittingToast });
await fileManager.uploadFile('event_request', record.id, 'invoice', formData.invoice);
}
// Upload multiple invoice files
if (formData.invoice_files && formData.invoice_files.length > 0) {
toast.loading('Uploading invoice files...', { id: submittingToast });
await fileManager.uploadFiles('event_request', record.id, 'invoice_files', formData.invoice_files);
}
// Clear form data from localStorage
localStorage.removeItem('eventRequestFormData');
// Show success message
toast.success('Event request submitted successfully!', { id: submittingToast });
// Reset form
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,
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,
taxRate: 7.75, // Default tax rate for San Diego
taxAmount: 0,
tipPercentage: 15, // Default tip percentage
tipAmount: 0,
total: 0,
vendor: ''
},
formReviewed: false // Reset review status
});
// Reset to first step
setCurrentStep(1);
} catch (uploadErr: any) {
console.error('Error during file upload:', uploadErr);
toast.error(`Error during file upload: ${uploadErr.message || 'Unknown error'}`, { id: submittingToast });
throw uploadErr;
}
} catch (err: any) {
console.error('Error submitting event request:', err);
setError(err.message || 'An error occurred while submitting your request');
toast.error(err.message || 'An error occurred while submitting your request', { id: submittingToast });
} 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 = () => {
if (!formData.name) {
toast.error('Please enter an event name');
return false;
}
if (!formData.event_description) {
toast.error('Please enter an event description');
return false;
}
if (!formData.start_date_time) {
toast.error('Please enter a start date and time');
return false;
}
if (!formData.end_date_time) {
toast.error('Please enter an end date and time');
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;
};
// Validate TAP Form Section
const validateTAPFormSection = () => {
if (!formData.expected_attendance) {
toast.error('Please enter the expected attendance');
return false;
}
if (!formData.room_booking && formData.will_or_have_room_booking) {
toast.error('Please upload your room booking confirmation');
return false;
}
if (formData.food_drinks_being_served === null || formData.food_drinks_being_served === undefined) {
toast.error('Please specify if food/drinks will be served');
return false;
}
return true;
};
// 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');
return false;
}
// Check if there are items in the invoice
if (formData.invoiceData.items.length === 0) {
toast.error('Please add at least one item to the invoice');
return false;
}
// 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');
return false;
}
}
return true;
};
// Validate all sections before submission
const validateAllSections = () => {
// Validate Event Details
if (!validateEventDetailsSection()) {
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) {
// 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"
>
<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"
>
{formData.food_drinks_being_served && (
<div className="bg-base-200/50 p-6 rounded-lg mb-6">
<h3 className="text-xl font-semibold mb-4">Do you need AS funding for this event?</h3>
<div className="flex flex-col sm:flex-row gap-4">
<button
className={`btn btn-lg ${formData.needs_as_funding ? 'btn-primary' : 'btn-outline'} flex-1`}
onClick={() => {
setFormData({ ...formData, needs_as_funding: true, as_funding_required: true });
}}
>
Yes
</button>
<button
className={`btn btn-lg ${!formData.needs_as_funding && formData.needs_as_funding !== null ? 'btn-primary' : 'btn-outline'} flex-1`}
onClick={() => {
setFormData({ ...formData, needs_as_funding: false, as_funding_required: false });
}}
>
No
</button>
</div>
</div>
)}
{!formData.food_drinks_being_served && (
<div className="bg-base-200/50 p-6 rounded-lg mb-6">
<h3 className="text-xl font-semibold mb-4">AS Funding Information</h3>
<p className="mb-4">Since you're not serving food or drinks, AS funding is not applicable for this event.</p>
<div className="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-5 w-5 stroke-current">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<p>If you need to request AS funding for other purposes, please contact the AS office directly.</p>
</div>
</div>
</div>
)}
{formData.needs_as_funding && formData.food_drinks_being_served && (
<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)}>
Review Form
</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} />
<div className="divider my-6">Ready to Submit?</div>
<div className="alert alert-info mb-6">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-5 w-5 stroke-current">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<p>Once submitted, you'll need to notify PR and/or Coordinators in the #-events Slack channel.</p>
</div>
</div>
</div>
<div className="flex justify-between mt-8">
<button className="btn btn-outline" onClick={() => setCurrentStep(5)}>
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 (
<>
<Toaster position="top-right" />
<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"
>
<AnimatePresence mode="wait">
{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 }}
className="alert alert-error shadow-lg"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 stroke-current" fill="none" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>{error}</span>
</motion.div>
)}
</AnimatePresence>
{/* 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;

View file

@ -0,0 +1,324 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import type { EventRequestFormData } from './EventRequestForm';
import type { InvoiceItem } from './InvoiceBuilder';
interface EventRequestFormPreviewProps {
formData?: EventRequestFormData; // Optional prop to directly pass form data
}
const EventRequestFormPreview: React.FC<EventRequestFormPreviewProps> = ({ formData: propFormData }) => {
const [formData, setFormData] = useState<EventRequestFormData | null>(propFormData || null);
const [loading, setLoading] = useState<boolean>(!propFormData);
// Load form data from localStorage on initial load and when updated
useEffect(() => {
// If formData is provided as a prop, use it directly
if (propFormData) {
setFormData(propFormData);
setLoading(false);
return;
}
const loadFormData = () => {
setLoading(true);
const savedData = localStorage.getItem('eventRequestFormData');
if (savedData) {
try {
const parsedData = JSON.parse(savedData);
setFormData(parsedData);
} catch (e) {
console.error('Error parsing saved form data:', e);
}
}
setLoading(false);
};
// Load initial data
loadFormData();
// Listen for form data updates
const handleFormDataUpdate = (event: CustomEvent) => {
if (event.detail && event.detail.formData) {
setFormData(event.detail.formData);
}
};
window.addEventListener('formDataUpdated', handleFormDataUpdate as EventListener);
document.addEventListener('updatePreview', loadFormData);
return () => {
window.removeEventListener('formDataUpdated', handleFormDataUpdate as EventListener);
document.removeEventListener('updatePreview', loadFormData);
};
}, [propFormData]);
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="loading loading-spinner loading-lg"></div>
</div>
);
}
if (!formData) {
return (
<div className="text-center py-12">
<h3 className="text-xl font-bold mb-4">No Form Data Available</h3>
<p className="text-gray-400">Please fill out the form to see a preview.</p>
</div>
);
}
// Format date and time for display
const formatDateTime = (dateTimeString: string) => {
if (!dateTimeString) return 'Not specified';
try {
const date = new Date(dateTimeString);
return date.toLocaleString();
} catch (e) {
return dateTimeString;
}
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-8"
>
<div className="bg-base-300 p-6 rounded-lg">
<h2 className="text-2xl font-bold mb-4">Event Request Preview</h2>
<p className="text-sm text-gray-400 mb-6">
This is a preview of your event request. Please review all information before submitting.
</p>
{/* Event Details Section */}
<div className="mb-8">
<h3 className="text-xl font-semibold mb-4 border-b border-base-content/20 pb-2">
Event Details
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-400">Event Name</p>
<p className="font-medium">{formData.name || 'Not specified'}</p>
</div>
<div>
<p className="text-sm text-gray-400">Location</p>
<p className="font-medium">{formData.location || 'Not specified'}</p>
</div>
<div className="md:col-span-2">
<p className="text-sm text-gray-400">Event Description</p>
<p className="font-medium whitespace-pre-line">{formData.event_description || 'Not specified'}</p>
</div>
<div>
<p className="text-sm text-gray-400">Start Date & Time</p>
<p className="font-medium">{formatDateTime(formData.start_date_time)}</p>
</div>
<div>
<p className="text-sm text-gray-400">End Date & Time</p>
<p className="font-medium">{formatDateTime(formData.end_date_time)}</p>
</div>
<div>
<p className="text-sm text-gray-400">Room Booking</p>
<p className="font-medium">{formData.will_or_have_room_booking ? 'Yes' : 'No'}</p>
</div>
<div>
<p className="text-sm text-gray-400">Expected Attendance</p>
<p className="font-medium">{formData.expected_attendance || 'Not specified'}</p>
</div>
</div>
</div>
{/* PR Materials Section */}
{formData.flyers_needed && (
<div className="mb-8">
<h3 className="text-xl font-semibold mb-4 border-b border-base-content/20 pb-2">
PR Materials
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-400">Flyer Types</p>
<ul className="list-disc list-inside">
{formData.flyer_type.map((type, index) => (
<li key={index} className="font-medium">
{type === 'digital_with_social' && 'Digital flyer with social media advertising'}
{type === 'digital_no_social' && 'Digital flyer without social media advertising'}
{type === 'physical_with_advertising' && 'Physical flyer with advertising'}
{type === 'physical_no_advertising' && 'Physical flyer without advertising'}
{type === 'newsletter' && 'Newsletter'}
{type === 'other' && 'Other: ' + formData.other_flyer_type}
</li>
))}
</ul>
</div>
<div>
<p className="text-sm text-gray-400">Advertising Start Date</p>
<p className="font-medium">{formData.flyer_advertising_start_date || 'Not specified'}</p>
</div>
<div className="md:col-span-2">
<p className="text-sm text-gray-400">Required Logos</p>
<div className="flex flex-wrap gap-2 mt-1">
{formData.required_logos.map((logo, index) => (
<span key={index} className="badge badge-primary">{logo}</span>
))}
{formData.required_logos.length === 0 && <p className="font-medium">None specified</p>}
</div>
</div>
<div>
<p className="text-sm text-gray-400">Advertising Format</p>
<p className="font-medium">
{formData.advertising_format === 'pdf' && 'PDF'}
{formData.advertising_format === 'jpeg' && 'JPEG'}
{formData.advertising_format === 'png' && 'PNG'}
{formData.advertising_format === 'does_not_matter' && 'Does not matter'}
{!formData.advertising_format && 'Not specified'}
</p>
</div>
<div>
<p className="text-sm text-gray-400">Photography Needed</p>
<p className="font-medium">{formData.photography_needed ? 'Yes' : 'No'}</p>
</div>
{formData.flyer_additional_requests && (
<div className="md:col-span-2">
<p className="text-sm text-gray-400">Additional Requests</p>
<p className="font-medium whitespace-pre-line">{formData.flyer_additional_requests}</p>
</div>
)}
</div>
</div>
)}
{/* TAP Form Section */}
<div className="mb-8">
<h3 className="text-xl font-semibold mb-4 border-b border-base-content/20 pb-2">
TAP Form Details
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-400">Expected Attendance</p>
<p className="font-medium">{formData.expected_attendance || 'Not specified'}</p>
</div>
<div>
<p className="text-sm text-gray-400">Room Booking</p>
<p className="font-medium">
{formData.room_booking ? formData.room_booking.name : 'No file uploaded'}
</p>
</div>
<div>
<p className="text-sm text-gray-400">AS Funding Required</p>
<p className="font-medium">{formData.as_funding_required ? 'Yes' : 'No'}</p>
</div>
<div>
<p className="text-sm text-gray-400">Food/Drinks Being Served</p>
<p className="font-medium">{formData.food_drinks_being_served ? 'Yes' : 'No'}</p>
</div>
</div>
</div>
{/* AS Funding Section */}
{formData.as_funding_required && (
<div className="mb-8">
<h3 className="text-xl font-semibold mb-4 border-b border-base-content/20 pb-2">
AS Funding Details
</h3>
<div className="grid grid-cols-1 gap-4 mb-4">
<div>
<p className="text-sm text-gray-400">Vendor</p>
<p className="font-medium">{formData.invoiceData.vendor || 'Not specified'}</p>
</div>
</div>
{formData.invoiceData.items.length > 0 ? (
<div className="overflow-x-auto mb-4">
<table className="table w-full">
<thead>
<tr>
<th>Description</th>
<th className="text-right">Qty</th>
<th className="text-right">Unit Price</th>
<th className="text-right">Amount</th>
</tr>
</thead>
<tbody>
{formData.invoiceData.items.map((item: InvoiceItem) => (
<tr key={item.id}>
<td>{item.description}</td>
<td className="text-right">{item.quantity}</td>
<td className="text-right">${item.unitPrice.toFixed(2)}</td>
<td className="text-right">${item.amount.toFixed(2)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={3} className="text-right font-medium">Subtotal:</td>
<td className="text-right">${formData.invoiceData.subtotal.toFixed(2)}</td>
</tr>
<tr>
<td colSpan={3} className="text-right font-medium">Tax ({formData.invoiceData.taxRate}%):</td>
<td className="text-right">${formData.invoiceData.taxAmount.toFixed(2)}</td>
</tr>
<tr>
<td colSpan={3} className="text-right font-medium">Tip ({formData.invoiceData.tipPercentage}%):</td>
<td className="text-right">${formData.invoiceData.tipAmount.toFixed(2)}</td>
</tr>
<tr>
<td colSpan={3} className="text-right font-bold">Total:</td>
<td className="text-right font-bold">${formData.invoiceData.total.toFixed(2)}</td>
</tr>
</tfoot>
</table>
</div>
) : (
<div className="alert alert-info mb-4">
<div>No invoice items have been added yet.</div>
</div>
)}
<div className="mt-4 mb-4">
<p className="text-sm text-gray-400 mb-2">JSON Format (For Submission):</p>
<pre className="bg-base-300 p-4 rounded-lg overflow-x-auto text-xs">
{JSON.stringify({
items: formData.invoiceData.items.map(item => ({
item: item.description,
quantity: item.quantity,
unit_price: item.unitPrice
})),
tax: formData.invoiceData.taxAmount,
tip: formData.invoiceData.tipAmount,
total: formData.invoiceData.total,
vendor: formData.invoiceData.vendor
}, null, 2)}
</pre>
<p className="text-xs text-gray-400 mt-2">
This is the structured format that will be submitted to our database.
It ensures that your invoice data is properly organized and can be
processed correctly by our system.
</p>
</div>
<div>
<p className="text-sm text-gray-400">Invoice Files</p>
{formData.invoice_files && formData.invoice_files.length > 0 ? (
<div className="mt-2 space-y-1">
{formData.invoice_files.map((file, index) => (
<p key={index} className="font-medium">{file.name}</p>
))}
</div>
) : formData.invoice ? (
<p className="font-medium">{formData.invoice.name}</p>
) : (
<p className="font-medium">No files uploaded</p>
)}
</div>
</div>
)}
</div>
</motion.div>
);
};
export default EventRequestFormPreview;

View file

@ -1,78 +0,0 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Icon } from '@iconify/react';
interface InfoCardProps {
title: string;
items: readonly string[] | string[];
type?: 'info' | 'warning' | 'success';
icon?: React.ReactNode;
className?: string;
}
const defaultIcons = {
info: <Icon icon="mdi:information-outline" className="text-info shrink-0 w-6 h-6" />,
warning: <Icon icon="mdi:alert-outline" className="text-warning shrink-0 w-6 h-6" />,
success: <Icon icon="mdi:check-circle-outline" className="text-success shrink-0 w-6 h-6" />
};
const typeStyles = {
info: 'alert-info bg-info/10',
warning: 'alert-warning bg-warning/10',
success: 'alert-success bg-success/10'
};
export const InfoCard: React.FC<InfoCardProps> = ({
title,
items,
type = 'info',
icon,
className = ''
}) => {
const listVariants = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, x: -20 },
show: { opacity: 1, x: 0 }
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={`alert ${typeStyles[type]} shadow-sm ${className}`}
>
{icon || defaultIcons[type]}
<div className="text-sm space-y-2 text-white">
<p className="font-medium text-white">{title}</p>
<motion.ul
className="space-y-1 ml-1 text-white"
variants={listVariants}
initial="hidden"
animate="show"
>
{items.map((item, index) => (
<motion.li
key={index}
variants={itemVariants}
className="flex items-start gap-2 text-white"
>
<span className="text-base leading-6 text-white"></span>
<span>{item}</span>
</motion.li>
))}
</motion.ul>
</div>
</motion.div>
);
};
export default InfoCard;

View file

@ -0,0 +1,406 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
// Animation variants
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
// Invoice item interface
export interface InvoiceItem {
id: string;
description: string;
quantity: number;
unitPrice: number;
amount: number;
}
// Invoice data interface
export interface InvoiceData {
items: InvoiceItem[];
subtotal: number;
taxRate: number;
taxAmount: number;
tipPercentage: number;
tipAmount: number;
total: number;
vendor: string;
}
interface InvoiceBuilderProps {
invoiceData: InvoiceData;
onChange: (data: InvoiceData) => void;
}
const InvoiceBuilder: React.FC<InvoiceBuilderProps> = ({ invoiceData, onChange }) => {
// State for new item form
const [newItem, setNewItem] = useState<Omit<InvoiceItem, 'id' | 'amount'>>({
description: '',
quantity: 1,
unitPrice: 0
});
// State for validation errors
const [errors, setErrors] = useState<{
description?: string;
quantity?: string;
unitPrice?: string;
vendor?: string;
}>({});
// Generate a unique ID for new items
const generateId = () => {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
};
// Calculate totals whenever invoice data changes
useEffect(() => {
calculateTotals();
}, [invoiceData.items, invoiceData.taxRate, invoiceData.tipPercentage]);
// Calculate all totals
const calculateTotals = () => {
// Calculate subtotal
const subtotal = invoiceData.items.reduce((sum, item) => sum + item.amount, 0);
// Calculate tax amount (ensure it's based on the current subtotal)
const taxAmount = subtotal * (invoiceData.taxRate / 100);
// Calculate tip amount (ensure it's based on the current subtotal)
const tipAmount = subtotal * (invoiceData.tipPercentage / 100);
// Calculate total
const total = subtotal + taxAmount + tipAmount;
// Update invoice data
onChange({
...invoiceData,
subtotal,
taxAmount,
tipAmount,
total
});
};
// Validate new item
const validateNewItem = () => {
const newErrors: {
description?: string;
quantity?: string;
unitPrice?: string;
} = {};
if (!newItem.description.trim()) {
newErrors.description = 'Description is required';
}
if (newItem.quantity <= 0) {
newErrors.quantity = 'Quantity must be greater than 0';
}
if (newItem.unitPrice <= 0) {
newErrors.unitPrice = 'Unit price must be greater than 0';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Add a new item
const handleAddItem = () => {
if (!validateNewItem()) {
return;
}
// Check for duplicate description
const isDuplicate = invoiceData.items.some(
item => item.description.toLowerCase() === newItem.description.toLowerCase()
);
if (isDuplicate) {
setErrors({ description: 'An item with this description already exists' });
return;
}
// Calculate amount
const amount = newItem.quantity * newItem.unitPrice;
// Create new item
const item: InvoiceItem = {
id: generateId(),
description: newItem.description,
quantity: newItem.quantity,
unitPrice: newItem.unitPrice,
amount
};
// Add item to invoice
onChange({
...invoiceData,
items: [...invoiceData.items, item]
});
// Reset new item form
setNewItem({
description: '',
quantity: 1,
unitPrice: 0
});
// Clear errors
setErrors({});
};
// Remove an item
const handleRemoveItem = (id: string) => {
onChange({
...invoiceData,
items: invoiceData.items.filter(item => item.id !== id)
});
};
// Update tax rate
const handleTaxRateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(e.target.value);
onChange({
...invoiceData,
taxRate: isNaN(value) ? 0 : value
});
};
// Update tip percentage
const handleTipPercentageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(e.target.value);
onChange({
...invoiceData,
tipPercentage: isNaN(value) ? 0 : value
});
};
// Update vendor
const handleVendorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange({
...invoiceData,
vendor: e.target.value
});
};
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
return (
<motion.div variants={itemVariants} className="space-y-6">
<div className="bg-base-200/50 p-4 rounded-lg">
<h3 className="text-lg font-semibold mb-4">Invoice Builder</h3>
{/* AS Funding Limit Notice */}
<div className="alert alert-warning mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 stroke-current" fill="none" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<span className="font-bold">AS Funding Limits:</span> Maximum of $10.00 per expected student attendee and $5,000 per event.
</div>
</div>
{/* Vendor information */}
<div className="form-control mb-4">
<label className="label">
<span className="label-text font-medium">Vendor/Restaurant Name</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
className={`input input-bordered ${errors.vendor ? 'input-error' : ''}`}
value={invoiceData.vendor}
onChange={handleVendorChange}
placeholder="e.g. L&L Hawaiian Barbeque"
/>
{errors.vendor && (
<label className="label">
<span className="label-text-alt text-error">{errors.vendor}</span>
</label>
)}
</div>
{/* Item list */}
<div className="overflow-x-auto mb-4">
<table className="table w-full">
<thead>
<tr>
<th>Description</th>
<th className="text-right">Qty</th>
<th className="text-right">Unit Price</th>
<th className="text-right">Amount</th>
<th></th>
</tr>
</thead>
<tbody>
{invoiceData.items.map(item => (
<tr key={item.id} className="hover">
<td>{item.description}</td>
<td className="text-right">{item.quantity}</td>
<td className="text-right">{formatCurrency(item.unitPrice)}</td>
<td className="text-right">{formatCurrency(item.amount)}</td>
<td className="text-right">
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => handleRemoveItem(item.id)}
>
<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>
</td>
</tr>
))}
{invoiceData.items.length === 0 && (
<tr>
<td colSpan={5} className="text-center py-4 text-gray-500">
No items added yet
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Add new item form */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Description</span>
</label>
<input
type="text"
className={`input input-bordered input-sm ${errors.description ? 'input-error' : ''}`}
value={newItem.description}
onChange={(e) => setNewItem({ ...newItem, description: e.target.value })}
placeholder="e.g. Chicken Cutlet with Gravy"
/>
{errors.description && (
<label className="label">
<span className="label-text-alt text-error">{errors.description}</span>
</label>
)}
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Quantity</span>
</label>
<input
type="number"
className={`input input-bordered input-sm ${errors.quantity ? 'input-error' : ''}`}
value={newItem.quantity}
onChange={(e) => setNewItem({ ...newItem, quantity: parseInt(e.target.value) || 0 })}
min="1"
step="1"
/>
{errors.quantity && (
<label className="label">
<span className="label-text-alt text-error">{errors.quantity}</span>
</label>
)}
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Unit Price ($)</span>
</label>
<input
type="number"
className={`input input-bordered input-sm ${errors.unitPrice ? 'input-error' : ''}`}
value={newItem.unitPrice}
onChange={(e) => setNewItem({ ...newItem, unitPrice: parseFloat(e.target.value) || 0 })}
min="0.01"
step="0.01"
/>
{errors.unitPrice && (
<label className="label">
<span className="label-text-alt text-error">{errors.unitPrice}</span>
</label>
)}
</div>
</div>
<div className="flex justify-end">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleAddItem}
>
Add Item
</button>
</div>
{/* Tax and tip */}
<div className="divider"></div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Tax Rate (%)</span>
</label>
<input
type="number"
className="input input-bordered input-sm"
value={invoiceData.taxRate}
onChange={handleTaxRateChange}
min="0"
step="0.01"
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Tip Percentage (%)</span>
</label>
<input
type="number"
className="input input-bordered input-sm"
value={invoiceData.tipPercentage}
onChange={handleTipPercentageChange}
min="0"
step="0.01"
/>
</div>
</div>
{/* Totals */}
<div className="bg-base-300/30 p-4 rounded-lg">
<div className="flex justify-between mb-2">
<span>Subtotal:</span>
<span className="font-medium">{formatCurrency(invoiceData.subtotal)}</span>
</div>
<div className="flex justify-between mb-2">
<span>Tax ({invoiceData.taxRate}%):</span>
<span className="font-medium">{formatCurrency(invoiceData.taxAmount)}</span>
</div>
<div className="flex justify-between mb-2">
<span>Tip ({invoiceData.tipPercentage}%):</span>
<span className="font-medium">{formatCurrency(invoiceData.tipAmount)}</span>
</div>
<div className="flex justify-between font-bold text-lg">
<span>Total:</span>
<span>{formatCurrency(invoiceData.total)}</span>
</div>
</div>
</div>
</motion.div>
);
};
export default InvoiceBuilder;

View file

@ -1,254 +1,262 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { toast } from 'react-hot-toast'; import { motion } from 'framer-motion';
import { Icon } from '@iconify/react'; import type { EventRequestFormData } from './EventRequestForm';
// Animation variants
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
// Flyer type options
const FLYER_TYPES = [
{ value: 'digital_with_social', label: 'Digital flyer (with social media advertising: Facebook, Instagram, Discord)' },
{ value: 'digital_no_social', label: 'Digital flyer (with NO social media advertising)' },
{ value: 'physical_with_advertising', label: 'Physical flyer (with advertising)' },
{ value: 'physical_no_advertising', label: 'Physical flyer (with NO advertising)' },
{ value: 'newsletter', label: 'Newsletter (IEEE, ECE, IDEA)' },
{ value: 'other', label: 'Other' }
];
// Logo options
const LOGO_OPTIONS = [
{ value: 'IEEE', label: 'IEEE' },
{ value: 'AS', label: 'AS (required if funded by AS)' },
{ value: 'HKN', label: 'HKN' },
{ value: 'TESC', label: 'TESC' },
{ value: 'PIB', label: 'PIB' },
{ value: 'TNT', label: 'TNT' },
{ value: 'SWE', label: 'SWE' },
{ value: 'OTHER', label: 'OTHER (please upload transparent logo files)' }
];
// Format options
const FORMAT_OPTIONS = [
{ value: 'pdf', label: 'PDF' },
{ value: 'jpeg', label: 'JPEG' },
{ value: 'png', label: 'PNG' },
{ value: 'does_not_matter', label: 'DOES NOT MATTER' }
];
interface PRSectionProps { interface PRSectionProps {
onDataChange?: (data: any) => void; formData: EventRequestFormData;
onDataChange: (data: Partial<EventRequestFormData>) => void;
} }
const PRSection: React.FC<PRSectionProps> = ({ onDataChange }) => { const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
const [selectedTypes, setSelectedTypes] = useState<string[]>([]); const [otherLogoFiles, setOtherLogoFiles] = useState<File[]>(formData.other_logos || []);
const [selectedLogos, setSelectedLogos] = useState<string[]>([]);
const flyerTypes = [ // Handle checkbox change for flyer types
{ value: 'digital_with_social', label: 'Digital flyer (with social media advertising: Facebook, Instagram, Discord)' }, const handleFlyerTypeChange = (type: string) => {
{ value: 'digital_no_social', label: 'Digital flyer (with NO social media advertising)' }, const updatedTypes = formData.flyer_type.includes(type)
{ value: 'physical_with_advertising', label: 'Physical flyer (with advertising)' }, ? formData.flyer_type.filter(t => t !== type)
{ value: 'physical_no_advertising', label: 'Physical flyer (with NO advertising)' }, : [...formData.flyer_type, type];
{ value: 'newsletter', label: 'Newsletter (IEEE, ECE, IDEA)' },
{ value: 'other', label: 'Other' }
];
const logoOptions = [ onDataChange({ flyer_type: updatedTypes });
{ value: 'IEEE', label: 'IEEE' },
{ value: 'AS', label: 'AS' },
{ value: 'HKN', label: 'HKN' },
{ value: 'TESC', label: 'TESC' },
{ value: 'PIB', label: 'PIB' },
{ value: 'TNT', label: 'TNT' },
{ value: 'SWE', label: 'SWE' },
{ value: 'OTHER', label: 'OTHER' }
];
const handleTypeChange = (value: string) => {
const newTypes = selectedTypes.includes(value)
? selectedTypes.filter(type => type !== value)
: [...selectedTypes, value];
setSelectedTypes(newTypes);
if (onDataChange) {
onDataChange({ flyer_type: newTypes });
}
}; };
const handleLogoChange = (value: string) => { // Handle checkbox change for required logos
const newLogos = selectedLogos.includes(value) const handleLogoChange = (logo: string) => {
? selectedLogos.filter(logo => logo !== value) const updatedLogos = formData.required_logos.includes(logo)
: [...selectedLogos, value]; ? formData.required_logos.filter(l => l !== logo)
setSelectedLogos(newLogos); : [...formData.required_logos, logo];
if (onDataChange) { onDataChange({ required_logos: updatedLogos });
onDataChange({ required_logos: newLogos });
}
}; };
const handleOtherLogosUpload = (e: React.ChangeEvent<HTMLInputElement>) => { // Handle file upload for other logos
const files = e.target.files; const handleLogoFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (files && files.length > 0) { if (e.target.files && e.target.files.length > 0) {
onDataChange?.({ other_logos: files }); const newFiles = Array.from(e.target.files);
toast.success(`${files.length} logo file(s) uploaded`); setOtherLogoFiles(newFiles);
onDataChange({ other_logos: newFiles });
} }
}; };
return ( return (
<div className="card bg-base-100/95 backdrop-blur-md shadow-lg"> <div className="space-y-6">
<div className="card-body"> <h2 className="text-2xl font-bold mb-4 text-primary">PR Materials</h2>
<h2 className="card-title text-xl mb-6 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
PR Materials
</h2>
<div className="space-y-4 mt-4"> <div className="bg-base-300/50 p-4 rounded-lg mb-6">
<label className="form-control w-full"> <p className="text-sm">
<div className="label"> If you need PR Materials, please don't forget that this form MUST be submitted at least 6 weeks in advance even if you aren't requesting AS funding or physical flyers. Also, please remember to ping PR in #-events on Slack once you've submitted this form.
<span className="label-text font-medium text-lg flex items-center gap-2"> </p>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor"> </div>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Type of material needed
</span>
</div>
<div className="space-y-2">
{flyerTypes.map(type => (
<label key={type.value} className="label cursor-pointer justify-start gap-3 hover:bg-base-200/50 p-4 rounded-lg transition-colors duration-300">
<input
type="checkbox"
className="checkbox checkbox-primary"
name="flyer_type[]"
value={type.value}
checked={selectedTypes.includes(type.value)}
onChange={() => handleTypeChange(type.value)}
/>
<span className="label-text">{type.label}</span>
</label>
))}
{selectedTypes.includes('other') && ( {/* Type of material needed */}
<div className="form-control w-full mt-2 ml-10"> <motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
<input <label className="label">
type="text" <span className="label-text font-medium text-lg">Type of material needed?</span>
name="other_flyer_type" <span className="label-text-alt text-error">*</span>
placeholder="Please specify other flyer type" </label>
className="input input-bordered w-full" <div className="space-y-2 mt-2">
onChange={(e) => onDataChange?.({ other_flyer_type: e.target.value })} {FLYER_TYPES.map((type) => (
/> <label key={type.value} className="flex items-start gap-2 cursor-pointer hover:bg-base-300/30 p-2 rounded-md transition-colors">
</div> <input
)} type="checkbox"
</div> className="checkbox checkbox-primary mt-1"
</label> checked={formData.flyer_type.includes(type.value)}
onChange={() => handleFlyerTypeChange(type.value)}
/>
<span>{type.label}</span>
</label>
))}
</div> </div>
{selectedTypes.length > 0 && ( {/* Other flyer type input */}
<> {formData.flyer_type.includes('other') && (
<div className="form-control w-full"> <div className="mt-3 pl-7">
<label className="label"> <input
<span className="label-text font-medium text-lg flex items-center gap-2"> type="text"
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor"> className="input input-bordered w-full"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> placeholder="Please specify other material needed"
</svg> value={formData.other_flyer_type}
Advertising Start Date onChange={(e) => onDataChange({ other_flyer_type: e.target.value })}
</span> required
</label> />
<input </div>
type="datetime-local"
name="flyer_advertising_start_date"
className="input input-bordered w-full"
onChange={(e) => onDataChange?.({ flyer_advertising_start_date: e.target.value })}
/>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text font-medium text-lg flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Logos Required
</span>
</label>
<div className="space-y-2">
{logoOptions.map(logo => (
<label key={logo.value} className="label cursor-pointer justify-start gap-3 hover:bg-base-200/50 p-4 rounded-lg transition-colors duration-300">
<input
type="checkbox"
className="checkbox checkbox-primary"
name="required_logos[]"
value={logo.value}
checked={selectedLogos.includes(logo.value)}
onChange={() => handleLogoChange(logo.value)}
/>
<span className="label-text">{logo.label}</span>
</label>
))}
</div>
</div>
{selectedLogos.includes('OTHER') && (
<div className="form-control w-full">
<label className="label">
<span className="label-text font-medium text-lg flex items-center gap-2">
<Icon icon="mdi:upload" className="h-5 w-5 text-primary" />
Upload Logo Files
</span>
</label>
<input
type="file"
name="other_logos"
multiple
accept="image/*"
className="file-input file-input-bordered w-full"
onChange={handleOtherLogosUpload}
/>
</div>
)}
<div className="form-control w-full">
<label className="label">
<span className="label-text font-medium text-lg flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Format Needed
</span>
</label>
<select
name="advertising_format"
className="select select-bordered w-full"
onChange={(e) => onDataChange?.({ advertising_format: e.target.value })}
>
<option value="">Select a format...</option>
<option value="pdf">PDF</option>
<option value="png">PNG</option>
<option value="jpeg">JPG</option>
<option value="does_not_matter">DOES NOT MATTER</option>
</select>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text font-medium text-lg flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Additional Specifications
</span>
</label>
<textarea
name="flyer_additional_requests"
className="textarea textarea-bordered h-32"
placeholder="Color scheme, overall design, examples to consider..."
onChange={(e) => onDataChange?.({ flyer_additional_requests: e.target.value })}
/>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text font-medium text-lg flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Photography Needed
</span>
</label>
<div className="flex gap-4">
<label className="label cursor-pointer justify-start gap-3 hover:bg-base-200/50 p-4 rounded-lg transition-colors duration-300">
<input
type="radio"
name="photography_needed"
value="true"
className="radio radio-primary"
onChange={(e) => onDataChange?.({ photography_needed: e.target.value === 'true' })}
/>
<span className="label-text">Yes</span>
</label>
<label className="label cursor-pointer justify-start gap-3 hover:bg-base-200/50 p-4 rounded-lg transition-colors duration-300">
<input
type="radio"
name="photography_needed"
value="false"
className="radio radio-primary"
onChange={(e) => onDataChange?.({ photography_needed: e.target.value === 'true' })}
/>
<span className="label-text">No</span>
</label>
</div>
</div>
</>
)} )}
</div> </motion.div>
{/* Advertising start date */}
{formData.flyer_type.some(type =>
type === 'digital_with_social' ||
type === 'physical_with_advertising' ||
type === 'newsletter'
) && (
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">When do you need us to start advertising?</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="date"
className="input input-bordered focus:input-primary transition-all duration-300"
value={formData.flyer_advertising_start_date}
onChange={(e) => onDataChange({ flyer_advertising_start_date: e.target.value })}
required
/>
</motion.div>
)}
{/* Logos Required */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Logos Required</span>
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{LOGO_OPTIONS.map((logo) => (
<label key={logo.value} className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
className="checkbox checkbox-primary mt-1"
checked={formData.required_logos.includes(logo.value)}
onChange={() => handleLogoChange(logo.value)}
/>
<span>{logo.label}</span>
</label>
))}
</div>
</motion.div>
{/* Logo file upload */}
{formData.required_logos.includes('OTHER') && (
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Please share your logo files here</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="file"
className="file-input file-input-bordered w-full file-input-primary hover:file-input-ghost transition-all duration-300"
onChange={handleLogoFileChange}
accept="image/*"
multiple
required
/>
{otherLogoFiles.length > 0 && (
<div className="mt-2">
<p className="text-sm font-medium mb-1">Selected files:</p>
<ul className="list-disc list-inside text-sm">
{otherLogoFiles.map((file, index) => (
<li key={index}>{file.name}</li>
))}
</ul>
</div>
)}
</motion.div>
)}
{/* Format */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">What format do you need it to be in?</span>
<span className="label-text-alt text-error">*</span>
</label>
<select
className="select select-bordered focus:select-primary transition-all duration-300"
value={formData.advertising_format}
onChange={(e) => onDataChange({ advertising_format: e.target.value })}
required
>
<option value="">Select format</option>
{FORMAT_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</motion.div>
{/* Additional specifications */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Any other specifications and requests?</span>
</label>
<textarea
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px]"
value={formData.flyer_additional_requests}
onChange={(e) => onDataChange({ flyer_additional_requests: e.target.value })}
placeholder="Color scheme, overall design, examples to consider, etc."
rows={4}
/>
</motion.div>
{/* Photography Needed */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Photography Needed?</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="radio radio-primary"
checked={formData.photography_needed === true}
onChange={() => onDataChange({ photography_needed: true })}
required
/>
<span>Yes</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="radio radio-primary"
checked={formData.photography_needed === false}
onChange={() => onDataChange({ photography_needed: false })}
required
/>
<span>No</span>
</label>
</div>
</motion.div>
</div> </div>
); );
}; };

View file

@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import type { EventRequestFormData } from './EventRequestForm';
// Animation variants
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
interface TAPFormSectionProps {
formData: EventRequestFormData;
onDataChange: (data: Partial<EventRequestFormData>) => void;
}
const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange }) => {
const [roomBookingFile, setRoomBookingFile] = useState<File | null>(formData.room_booking);
// Handle room booking file upload
const handleRoomBookingFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
setRoomBookingFile(file);
onDataChange({ room_booking: file });
}
};
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold mb-4 text-primary">TAP Form Information</h2>
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
<p className="text-sm">
Please ensure you have ALL sections completed. If something is not available, let the coordinators know and be advised on how to proceed.
</p>
</div>
{/* Expected attendance */}
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
<label className="label">
<span className="label-text font-medium text-lg">Expected attendance? Include a number NOT a range please.</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="relative mt-2">
<input
type="number"
className="input input-bordered focus:input-primary transition-all duration-300 w-full"
value={formData.expected_attendance || ''}
onChange={(e) => onDataChange({ expected_attendance: parseInt(e.target.value) || 0 })}
min="0"
placeholder="Enter expected attendance"
required
/>
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-gray-400">
people
</div>
</div>
<div className="text-xs text-gray-400 mt-2">
<p>PROGRAMMING FUNDS EVENTS FUNDED BY PROGRAMMING FUNDS MAY ONLY ADMIT UC SAN DIEGO STUDENTS, STAFF OR FACULTY AS GUESTS.</p>
<p>ONLY UC SAN DIEGO UNDERGRADUATE STUDENTS MAY RECEIVE ITEMS FUNDED BY THE ASSOCIATED STUDENTS.</p>
<p>EVENT FUNDING IS GRANTED UP TO A MAXIMUM OF $10.00 PER EXPECTED STUDENT ATTENDEE AND $5,000 PER EVENT</p>
</div>
</motion.div>
{/* Room booking confirmation */}
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
<label className="label">
<span className="label-text font-medium text-lg">Room booking confirmation</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="mt-2">
<input
type="file"
className="file-input file-input-bordered file-input-primary w-full"
onChange={handleRoomBookingFileChange}
accept=".pdf,.png,.jpg,.jpeg"
/>
{roomBookingFile && (
<p className="text-sm mt-2">
Selected file: {roomBookingFile.name}
</p>
)}
<p className="text-xs text-gray-500 mt-2">
Please upload a screenshot of your room booking confirmation. Accepted formats: PDF, PNG, JPG.
</p>
</div>
</motion.div>
{/* Food/Drinks */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Will you be serving food/drinks at your event?</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="radio radio-primary"
checked={formData.food_drinks_being_served === true}
onChange={() => onDataChange({ food_drinks_being_served: true })}
required
/>
<span>Yes</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="radio radio-primary"
checked={formData.food_drinks_being_served === false}
onChange={() => onDataChange({ food_drinks_being_served: false })}
required
/>
<span>No</span>
</label>
</div>
</motion.div>
{/* AS Funding Notice - only show if food/drinks are being served */}
{formData.food_drinks_being_served && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="alert alert-info"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-5 w-5 stroke-current">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 className="font-bold">Food and Drinks Information</h3>
<div className="text-xs">
If you're serving food or drinks, you'll be asked about AS funding in the next step. Please be prepared with vendor information and invoice details.
</div>
</div>
</motion.div>
)}
</div>
);
};
export default TAPFormSection;

View file

@ -1,395 +0,0 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'react-hot-toast';
import InfoCard from './InfoCard';
import Tooltip from './Tooltip';
import { tooltips, infoNotes } from './tooltips';
import { Icon } from '@iconify/react';
import { FileManager } from '../../../scripts/pocketbase/FileManager';
import type { ASFundingSectionProps, InvoiceItem } from './ASFundingSection';
interface TAPSectionProps {
onDataChange?: (data: any) => void;
onASFundingChange?: (enabled: boolean) => void;
children?: React.ReactElement<ASFundingSectionProps>;
}
interface TAPData {
expected_attendance: number;
room_booking: string | File;
as_funding_required: boolean;
food_drinks_being_served: boolean;
itemized_items?: InvoiceItem[];
}
const TAPSection: React.FC<TAPSectionProps> = ({ onDataChange, onASFundingChange, children }) => {
const [expectedAttendance, setExpectedAttendance] = useState<number>(0);
const [roomBooking, setRoomBooking] = useState<string>('');
const [needsASFunding, setNeedsASFunding] = useState<boolean>(false);
const [needsFoodDrinks, setNeedsFoodDrinks] = useState<boolean>(false);
const [roomBookingFile, setRoomBookingFile] = useState<File | null>(null);
const [itemizedItems, setItemizedItems] = useState<InvoiceItem[]>([]);
const fileManager = FileManager.getInstance();
const handleAttendanceChange = (value: number) => {
setExpectedAttendance(value);
if (value > 100) {
toast.custom((t) => (
<div className="alert alert-warning">
<Icon icon="mdi:warning" className="h-6 w-6" />
<span>Large attendance detected! Please ensure proper room capacity.</span>
</div>
), { duration: 4000 });
}
onDataChange?.({ expected_attendance: value });
};
const handleRoomBookingChange = (value: string) => {
setRoomBooking(value);
onDataChange?.({ room_booking: value });
};
const handleASFundingChange = (enabled: boolean) => {
setNeedsASFunding(enabled);
if (!enabled) {
setNeedsFoodDrinks(false);
setItemizedItems([]);
onDataChange?.({ food_drinks_being_served: false });
}
onASFundingChange?.(enabled);
onDataChange?.({
as_funding_required: enabled,
itemized_items: enabled ? itemizedItems : undefined
});
toast.custom((t) => (
<div className={`alert ${enabled ? 'alert-info' : 'alert-warning'}`}>
<Icon icon={enabled ? 'mdi:cash' : 'mdi:cash-off'} className="h-6 w-6" />
<span>{enabled ? 'AS Funding enabled - please fill out funding details.' : 'AS Funding disabled'}</span>
</div>
), { duration: 3000 });
};
const handleFoodDrinksChange = (enabled: boolean) => {
setNeedsFoodDrinks(enabled);
onDataChange?.({ food_drinks_being_served: enabled });
};
const handleRoomBookingFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setRoomBookingFile(file);
onDataChange?.({ room_booking: file });
toast.custom((t) => (
<div className="alert alert-success">
<Icon icon="mdi:check-circle" className="h-6 w-6" />
<span>Room booking file uploaded successfully</span>
</div>
));
}
};
const uploadRoomBookingFile = async (recordId: string) => {
if (roomBookingFile) {
try {
await fileManager.uploadFile(
'event_request',
recordId,
'room_booking',
roomBookingFile
);
} catch (error) {
console.error('Failed to upload room booking file:', error);
toast.custom((t) => (
<div className="alert alert-error">
<Icon icon="mdi:error" className="h-6 w-6" />
<span>Failed to upload room booking file</span>
</div>
), { duration: 4000 });
}
}
};
const handleItemizedItemsUpdate = (items: InvoiceItem[]) => {
setItemizedItems(items);
onDataChange?.({ itemized_items: items });
};
return (
<>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="card bg-base-100/95 backdrop-blur-md shadow-lg hover:shadow-xl transition-all duration-300"
>
<div className="card-body">
<motion.h2
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="card-title text-xl mb-6 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent flex items-center gap-2"
>
<Icon icon="mdi:clipboard-text-outline" className="h-6 w-6" />
TAP Form
</motion.h2>
<div className="space-y-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="form-control w-full"
>
<div className="flex items-center justify-between mb-2">
<label className="label">
<span className="label-text font-medium text-lg flex items-center gap-2">
<Icon icon="mdi:account-group" className="h-5 w-5 text-primary" />
Expected Attendance
</span>
</label>
<Tooltip
title={tooltips.attendance.title}
description={tooltips.attendance.description}
position="left"
>
<div className="badge badge-primary badge-outline p-3 cursor-help">
<Icon icon="mdi:information-outline" className="h-4 w-4" />
</div>
</Tooltip>
</div>
<InfoCard
title={infoNotes.funding.title}
items={infoNotes.funding.items}
type="warning"
className="mb-4"
/>
<div className="relative group">
<input
type="number"
min="0"
name="expected_attendance"
className="input input-bordered w-full pl-12 transition-all duration-300 focus:ring-2 focus:ring-primary/20"
value={expectedAttendance}
onChange={(e) => handleAttendanceChange(parseInt(e.target.value) || 0)}
required
/>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="form-control w-full"
>
<div className="flex items-center justify-between mb-2">
<label className="label">
<span className="label-text font-medium text-lg flex items-center gap-2">
<Icon icon="mdi:office-building-outline" className="h-5 w-5 text-primary" />
Room Booking
</span>
</label>
<Tooltip
title={tooltips.room.title}
description={tooltips.room.description}
position="left"
>
<div className="badge badge-primary badge-outline p-3 cursor-help">
<Icon icon="mdi:information-outline" className="h-4 w-4" />
</div>
</Tooltip>
</div>
<InfoCard
title={infoNotes.room.title}
items={infoNotes.room.items}
type="info"
className="mb-4"
/>
<div className="relative group">
<input
type="text"
placeholder="Enter room number and building (e.g. EBU1 2315)"
className="input input-bordered w-full pl-12 transition-all duration-300 focus:ring-2 focus:ring-primary/20"
value={roomBooking}
onChange={(e) => handleRoomBookingChange(e.target.value)}
required
/>
</div>
<div className="form-control w-full mt-4">
<label className="label">
<span className="label-text font-medium text-lg flex items-center gap-2">
<Icon icon="mdi:upload" className="h-5 w-5 text-primary" />
Room Booking File Upload
</span>
</label>
<div className="flex flex-col space-y-2">
<input
type="file"
name="room_booking"
className="file-input file-input-bordered file-input-primary w-full"
onChange={handleRoomBookingFileChange}
accept=".pdf,.jpg,.jpeg,.png"
/>
<div className="text-xs text-gray-500">
Max file size: 50MB
</div>
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="form-control w-full"
>
<div className="flex items-center justify-between mb-2">
<label className="label">
<span className="label-text font-medium text-lg flex items-center gap-2">
<Icon icon="mdi:cash" className="h-5 w-5 text-primary" />
AS Funding
</span>
</label>
<Tooltip
title={tooltips.asFunding.title}
description={tooltips.asFunding.description}
position="left"
>
<div className="badge badge-primary badge-outline p-3 cursor-help">
<Icon icon="mdi:information-outline" className="h-4 w-4" />
</div>
</Tooltip>
</div>
<div className="flex flex-col gap-2">
<label className="label cursor-pointer justify-start gap-4 hover:bg-base-200/50 p-4 rounded-lg transition-colors duration-300">
<input
type="radio"
name="as_funding"
className="radio radio-primary"
checked={needsASFunding}
onChange={() => handleASFundingChange(true)}
value="true"
/>
<span className="label-text">Yes, I need AS Funding</span>
</label>
<label className="label cursor-pointer justify-start gap-4 hover:bg-base-200/50 p-4 rounded-lg transition-colors duration-300">
<input
type="radio"
name="as_funding"
className="radio radio-primary"
checked={!needsASFunding}
onChange={() => handleASFundingChange(false)}
value="false"
/>
<span className="label-text">No, I don't need AS Funding</span>
</label>
</div>
</motion.div>
<AnimatePresence mode="wait">
{needsASFunding && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="form-control w-full overflow-hidden"
>
<div className="flex items-center justify-between mb-2">
<label className="label">
<span className="label-text font-medium text-lg flex items-center gap-2">
<Icon icon="mdi:food" className="h-5 w-5 text-primary" />
Food/Drinks
</span>
</label>
<Tooltip
title={tooltips.food.title}
description={tooltips.food.description}
position="left"
>
<div className="badge badge-primary badge-outline p-3 cursor-help">
<Icon icon="mdi:information-outline" className="h-4 w-4" />
</div>
</Tooltip>
</div>
<div className="flex flex-col gap-2">
<label className="label cursor-pointer justify-start gap-4 hover:bg-base-200/50 p-4 rounded-lg transition-colors duration-300">
<input
type="radio"
name="food_drinks"
className="radio radio-primary"
checked={needsFoodDrinks}
onChange={() => handleFoodDrinksChange(true)}
value="true"
/>
<span className="label-text">Yes, I need food/drinks</span>
</label>
<label className="label cursor-pointer justify-start gap-4 hover:bg-base-200/50 p-4 rounded-lg transition-colors duration-300">
<input
type="radio"
name="food_drinks"
className="radio radio-primary"
checked={!needsFoodDrinks}
onChange={() => handleFoodDrinksChange(false)}
value="false"
/>
<span className="label-text">No, I don't need food/drinks</span>
</label>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
<input
type="hidden"
name="itemized_items"
value={JSON.stringify(itemizedItems)}
/>
<input
type="hidden"
name="itemized_invoice"
value={JSON.stringify({
items: itemizedItems,
tax: 0,
tip: 0,
total: itemizedItems.reduce((sum, item) => sum + (item.quantity * item.unit_cost), 0),
vendor: ''
})}
/>
<AnimatePresence mode="popLayout">
{needsASFunding && (
<motion.div
initial={{ opacity: 0, height: 0, y: -20 }}
animate={{ opacity: 1, height: 'auto', y: 0 }}
exit={{ opacity: 0, height: 0, y: -20 }}
transition={{
duration: 0.3,
height: { duration: 0.4 },
opacity: { duration: 0.3 },
y: { duration: 0.3 }
}}
className="mt-8"
>
{children && React.cloneElement(children, {
onItemizedItemsUpdate: handleItemizedItemsUpdate
})}
</motion.div>
)}
</AnimatePresence>
</>
);
};
export default TAPSection;

View file

@ -1,157 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Icon } from '@iconify/react';
interface TooltipProps {
title: string;
description: string;
children: React.ReactNode;
className?: string;
position?: 'top' | 'bottom' | 'left' | 'right';
icon?: string;
maxWidth?: string;
}
// Define a small safety margin (in pixels) to keep tooltip from touching viewport edges
const VIEWPORT_MARGIN = 8;
const positionStyles = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2'
};
const arrowStyles = {
top: 'bottom-[-6px] left-1/2 -translate-x-1/2 border-t-base-200 border-l-transparent border-r-transparent border-b-transparent',
bottom: 'top-[-6px] left-1/2 -translate-x-1/2 border-b-base-200 border-l-transparent border-r-transparent border-t-transparent',
left: 'right-[-6px] top-1/2 -translate-y-1/2 border-l-base-200 border-t-transparent border-b-transparent border-r-transparent',
right: 'left-[-6px] top-1/2 -translate-y-1/2 border-r-base-200 border-t-transparent border-b-transparent border-l-transparent'
};
export const Tooltip: React.FC<TooltipProps> = ({
title,
description,
children,
className = '',
position = 'left',
icon = 'mdi:information',
maxWidth = '350px'
}) => {
const [isVisible, setIsVisible] = useState(false);
const [currentPosition, setCurrentPosition] = useState(position);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const tooltipRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isVisible || !tooltipRef.current || !containerRef.current) return;
const updatePosition = () => {
const tooltip = tooltipRef.current!;
const container = containerRef.current!;
const tooltipRect = tooltip.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Calculate overflow amounts
const overflowRight = Math.max(0, tooltipRect.right - (viewportWidth - VIEWPORT_MARGIN));
const overflowLeft = Math.max(0, VIEWPORT_MARGIN - tooltipRect.left);
const overflowTop = Math.max(0, VIEWPORT_MARGIN - tooltipRect.top);
const overflowBottom = Math.max(0, tooltipRect.bottom - (viewportHeight - VIEWPORT_MARGIN));
// Initialize offset adjustments
let xOffset = 0;
let yOffset = 0;
// Determine best position and calculate offsets
let newPosition = position;
if (position === 'left' || position === 'right') {
if (position === 'left' && overflowLeft > 0) {
newPosition = 'right';
} else if (position === 'right' && overflowRight > 0) {
newPosition = 'left';
}
// Adjust vertical position if needed
if (overflowTop > 0) {
yOffset = overflowTop;
} else if (overflowBottom > 0) {
yOffset = -overflowBottom;
}
} else {
if (position === 'top' && overflowTop > 0) {
newPosition = 'bottom';
} else if (position === 'bottom' && overflowBottom > 0) {
newPosition = 'top';
}
// Adjust horizontal position if needed
if (overflowRight > 0) {
xOffset = -overflowRight;
} else if (overflowLeft > 0) {
xOffset = overflowLeft;
}
}
setCurrentPosition(newPosition);
setOffset({ x: xOffset, y: yOffset });
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition);
};
}, [isVisible, position]);
return (
<div
ref={containerRef}
className={`relative inline-block ${className}`}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
>
{children}
<AnimatePresence>
{isVisible && (
<motion.div
ref={tooltipRef}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{
duration: 0.15,
ease: 'easeOut'
}}
style={{
maxWidth,
width: 'min(90vw, 350px)',
transform: `translate(${offset.x}px, ${offset.y}px)`
}}
className={`absolute z-50 p-3 bg-base-200/95 border border-base-300 rounded-lg shadow-lg backdrop-blur-sm
${positionStyles[currentPosition]}`}
>
<div className={`absolute w-0 h-0 border-[6px] ${arrowStyles[currentPosition]}`} />
<div className="flex items-start gap-2">
<Icon icon={icon} className="w-5 h-5 text-primary mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="font-medium text-base text-base-content break-words">{title}</h3>
<p className="text-sm leading-relaxed text-base-content/80 mt-0.5 whitespace-pre-wrap break-words">{description}</p>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default Tooltip;

View file

@ -1,99 +0,0 @@
export const tooltips = {
attendance: {
title: "Expected Attendance",
description:
"Enter the total number of expected attendees. This helps us plan resources and funding appropriately.",
maxLimit: "Maximum funding is $10 per student, up to $5,000 per event.",
eligibility:
"Only UCSD students, staff, and faculty are eligible to attend.",
},
room: {
title: "Room Booking",
description:
"Enter the room number and building where your event will be held. Make sure the room capacity matches your expected attendance.",
format: "Format: Building Room# (e.g. EBU1 2315)",
requirements: "Room must be booked through the appropriate UCSD channels.",
},
asFunding: {
title: "AS Funding",
description:
"Associated Students can provide funding for your event. Select this option if you need financial support.",
maxAmount: "Maximum funding varies based on event type and attendance.",
requirements: "Must submit request at least 6 weeks before event.",
},
food: {
title: "Food & Drinks",
description:
"Indicate if you plan to serve food or drinks at your event. This requires additional approvals and documentation.",
requirements:
"Must use approved vendors and follow food safety guidelines.",
timing: "Food orders must be finalized 2 weeks before event.",
},
vendor: {
title: "Vendor Information",
description:
"Enter the name and location of the vendor you plan to use for food/drinks.",
requirements: "Must be an approved AS Funding vendor.",
format: "Format: Vendor Name - Location",
},
invoice: {
title: "Invoice Details",
description: "Provide itemized details of your planned purchases.",
requirements:
"All items must be clearly listed with quantities and unit costs.",
format: "Official invoices required 2 weeks before event.",
},
tax: {
title: "Sales Tax",
description: "Enter the total sales tax amount from your invoice.",
note: "California sales tax is typically 7.75%",
},
tip: {
title: "Gratuity",
description: "Enter the tip amount if applicable.",
note: "Maximum 15% for delivery orders.",
},
total: {
title: "Total Amount",
description: "The total cost including items, tax, and tip.",
note: "Cannot exceed your approved funding amount.",
},
} as const;
export const infoNotes = {
funding: {
title: "Funding Guidelines",
items: [
"Events funded by programming funds may only admit UC San Diego students, staff or faculty as guests.",
"Only UC San Diego undergraduate students may receive items funded by the Associated Students.",
"Event funding is granted up to $10 per student, with a maximum of $5,000 per event.",
"Submit all documentation at least 6 weeks before the event.",
],
},
room: {
title: "Room Booking Format",
items: [
"Use the format: Building Room# (e.g. EBU1 2315)",
"Make sure the room capacity matches your expected attendance",
"Book through the appropriate UCSD channels",
"Include any special equipment needs in your request",
],
},
asFunding: {
title: "AS Funding Requirements",
items: [
"Please make sure the restaurant is a valid AS Funding food vendor!",
"Make sure to include all items, prices, and additional costs.",
"We don't recommend paying out of pocket as reimbursements can be complex.",
"Submit all documentation at least 6 weeks before the event.",
],
},
invoice: {
title: "Invoice Requirements",
items: [
"Official food invoices will be required 2 weeks before the start of your event.",
"Format: EventName_OrderLocation_DateOfEvent",
"Example: QPWorkathon#1_PapaJohns_01/06/2025",
],
},
} as const;

View file

@ -13,7 +13,22 @@ function convertLocalToUTC<T>(data: T): T {
const converted = { ...data }; const converted = { ...data };
for (const [key, value] of Object.entries(converted)) { for (const [key, value] of Object.entries(converted)) {
if (isLocalDateString(value)) { // Special handling for invoice_data to ensure it's a proper JSON object
if (key === "invoice_data") {
if (typeof value === "string") {
try {
// If it's a string representation of JSON, parse it
const parsedValue = JSON.parse(value);
(converted as any)[key] = parsedValue;
} catch (e) {
// If it's not valid JSON, keep it as is
console.warn("Failed to parse invoice_data as JSON:", e);
}
} else if (typeof value === "object" && value !== null) {
// If it's already an object, keep it as is
(converted as any)[key] = value;
}
} else if (isLocalDateString(value)) {
(converted as any)[key] = new Date(value).toISOString(); (converted as any)[key] = new Date(value).toISOString();
} else if (Array.isArray(value)) { } else if (Array.isArray(value)) {
(converted as any)[key] = value.map((item) => convertLocalToUTC(item)); (converted as any)[key] = value.map((item) => convertLocalToUTC(item));
@ -60,7 +75,9 @@ export class Update {
this.auth.setUpdating(true); this.auth.setUpdating(true);
const pb = this.auth.getPocketBase(); const pb = this.auth.getPocketBase();
const convertedData = convertLocalToUTC(data); const convertedData = convertLocalToUTC(data);
const result = await pb.collection(collectionName).create<T>(convertedData); const result = await pb
.collection(collectionName)
.create<T>(convertedData);
return result; return result;
} catch (err) { } catch (err) {
console.error(`Failed to create record in ${collectionName}:`, err); console.error(`Failed to create record in ${collectionName}:`, err);
@ -125,12 +142,12 @@ export class Update {
this.auth.setUpdating(true); this.auth.setUpdating(true);
const pb = this.auth.getPocketBase(); const pb = this.auth.getPocketBase();
const convertedUpdates = convertLocalToUTC(updates); const convertedUpdates = convertLocalToUTC(updates);
// If recordId is empty, create a new record instead of updating // If recordId is empty, create a new record instead of updating
if (!recordId) { if (!recordId) {
return this.create<T>(collectionName, convertedUpdates); return this.create<T>(collectionName, convertedUpdates);
} }
const result = await pb const result = await pb
.collection(collectionName) .collection(collectionName)
.update<T>(recordId, convertedUpdates); .update<T>(recordId, convertedUpdates);