better event form
This commit is contained in:
parent
8a2423eb72
commit
4065105bab
13 changed files with 2311 additions and 2083 deletions
|
@ -4,551 +4,78 @@ import { Update } from "../../scripts/pocketbase/Update";
|
|||
import { FileManager } from "../../scripts/pocketbase/FileManager";
|
||||
import { Get } from "../../scripts/pocketbase/Get";
|
||||
import { toast, Toaster } from "react-hot-toast";
|
||||
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
import EventRequestFormPreview from "./Officer_EventRequestForm/EventRequestFormPreview";
|
||||
import EventRequestForm from "./Officer_EventRequestForm/EventRequestForm";
|
||||
---
|
||||
|
||||
<div class="w-full max-w-4xl mx-auto p-6">
|
||||
<toast client:load />
|
||||
<h1
|
||||
class="text-3xl font-bold mb-8 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"
|
||||
>
|
||||
Event Request Form
|
||||
</h1>
|
||||
<div class="w-full max-w-6xl mx-auto py-8 px-4">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Event Request Form</h1>
|
||||
<p class="text-gray-300 mb-4">
|
||||
Submit your event request at least 6 weeks before your event. After
|
||||
submitting, please notify PR and/or Coordinators in the #-events Slack
|
||||
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">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="tabs tabs-boxed bg-base-200/50 backdrop-blur-sm p-1 rounded-lg mb-6">
|
||||
<input type="radio" name="form_tabs" id="new-request-tab" class="tab-toggle" checked />
|
||||
<input type="radio" name="form_tabs" id="my-requests-tab" class="tab-toggle" />
|
||||
|
||||
<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>
|
||||
<div class="bg-base-200 rounded-lg shadow-xl overflow-hidden">
|
||||
<div class="tabs tabs-boxed bg-base-300 rounded-t-lg">
|
||||
<button id="form-tab" class="tab tab-active">Submit Request</button>
|
||||
<button id="preview-tab" class="tab">Preview Request</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-panels mt-6">
|
||||
<div class="tab-panel" id="new-request-panel">
|
||||
<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="form-view" class="p-6">
|
||||
<EventRequestForm client:load />
|
||||
</div>
|
||||
|
||||
<div id="prSection" class="hidden">
|
||||
<PRSection 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 id="preview-view" class="p-6 hidden">
|
||||
<EventRequestFormPreview client:load />
|
||||
</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>
|
||||
import { Authentication } from "../../scripts/pocketbase/Authentication";
|
||||
import { Update } from "../../scripts/pocketbase/Update";
|
||||
import { FileManager } from "../../scripts/pocketbase/FileManager";
|
||||
import { Get } from "../../scripts/pocketbase/Get";
|
||||
import { toast } from "react-hot-toast";
|
||||
// Tab switching logic
|
||||
const formTab = document.getElementById("form-tab");
|
||||
const previewTab = document.getElementById("preview-tab");
|
||||
const formView = document.getElementById("form-view");
|
||||
const previewView = document.getElementById("preview-view");
|
||||
|
||||
// Add TypeScript interfaces
|
||||
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
|
||||
}
|
||||
formTab?.addEventListener("click", () => {
|
||||
formTab.classList.add("tab-active");
|
||||
previewTab?.classList.remove("tab-active");
|
||||
formView?.classList.remove("hidden");
|
||||
previewView?.classList.add("hidden");
|
||||
|
||||
// Extend Window interface
|
||||
declare global {
|
||||
interface Window {
|
||||
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");
|
||||
}
|
||||
});
|
||||
// Dispatch event to notify the preview component to update
|
||||
const event = new CustomEvent("updatePreview");
|
||||
document.dispatchEvent(event);
|
||||
});
|
||||
|
||||
// Form submission handler
|
||||
form?.addEventListener("submit", async (e) => {
|
||||
previewTab?.addEventListener("click", (e) => {
|
||||
// Prevent default behavior to avoid form submission
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const auth = Authentication.getInstance();
|
||||
const update = Update.getInstance();
|
||||
const userId = auth.getUserId();
|
||||
previewTab.classList.add("tab-active");
|
||||
formTab?.classList.remove("tab-active");
|
||||
previewView?.classList.remove("hidden");
|
||||
formView?.classList.add("hidden");
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
// 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.');
|
||||
}
|
||||
// Dispatch event to notify the preview component to update
|
||||
const event = new CustomEvent("updatePreview");
|
||||
document.dispatchEvent(event);
|
||||
});
|
||||
|
||||
// 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>
|
||||
|
|
|
@ -1,475 +1,190 @@
|
|||
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 { motion } from 'framer-motion';
|
||||
import type { EventRequestFormData } from './EventRequestForm';
|
||||
import InvoiceBuilder from './InvoiceBuilder';
|
||||
import type { InvoiceData } from './InvoiceBuilder';
|
||||
|
||||
export interface InvoiceItem {
|
||||
quantity: number;
|
||||
item_name: string;
|
||||
unit_cost: number;
|
||||
// Animation variants
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface ASFundingSectionProps {
|
||||
formData: EventRequestFormData;
|
||||
onDataChange: (data: Partial<EventRequestFormData>) => void;
|
||||
}
|
||||
|
||||
interface InvoiceData {
|
||||
items: InvoiceItem[];
|
||||
tax: number;
|
||||
tip: number;
|
||||
total: number;
|
||||
vendor: string;
|
||||
}
|
||||
const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataChange }) => {
|
||||
const [invoiceFile, setInvoiceFile] = useState<File | null>(formData.invoice);
|
||||
const [invoiceFiles, setInvoiceFiles] = useState<File[]>(formData.invoice_files || []);
|
||||
|
||||
export interface ASFundingSectionProps {
|
||||
onDataChange?: (data: any) => void;
|
||||
onItemizedItemsUpdate?: (items: InvoiceItem[]) => void;
|
||||
}
|
||||
|
||||
const ASFundingSection: React.FC<ASFundingSectionProps> = ({ onDataChange, onItemizedItemsUpdate }) => {
|
||||
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');
|
||||
// Handle single invoice file upload (for backward compatibility)
|
||||
const handleInvoiceFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const file = e.target.files[0];
|
||||
setInvoiceFile(file);
|
||||
onDataChange({ invoice: file });
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtraChange = (field: 'tax' | 'tip' | 'vendor', value: string | number) => {
|
||||
const numValue = field !== 'vendor' ? Number(value) : value;
|
||||
const itemsTotal = invoiceData.items.reduce((sum, item) => sum + (item.quantity * item.unit_cost), 0);
|
||||
// Handle multiple invoice files upload
|
||||
const handleMultipleInvoiceFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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' ?
|
||||
itemsTotal + Number(value) + (invoiceData.tip || 0) :
|
||||
field === 'tip' ?
|
||||
itemsTotal + (invoiceData.tax || 0) + Number(value) :
|
||||
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));
|
||||
// Also set the first file as the main invoice file for backward compatibility
|
||||
if (files.length > 0 && !formData.invoice) {
|
||||
setInvoiceFile(files[0]);
|
||||
onDataChange({ invoice: files[0] });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onDataChange?.({ invoice: file });
|
||||
toast('Invoice file uploaded');
|
||||
// Remove an invoice file
|
||||
const handleRemoveInvoiceFile = (index: number) => {
|
||||
const updatedFiles = [...invoiceFiles];
|
||||
updatedFiles.splice(index, 1);
|
||||
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 (
|
||||
<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:cash" className="h-6 w-6" />
|
||||
AS Funding Details
|
||||
</motion.h2>
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold mb-4 text-primary">AS Funding Information</h2>
|
||||
|
||||
<div className="space-y-8">
|
||||
<InfoCard
|
||||
title={infoNotes.asFunding.title}
|
||||
items={infoNotes.asFunding.items}
|
||||
type="warning"
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
<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 className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||
<div className="flex items-start gap-2">
|
||||
<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">
|
||||
<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" />
|
||||
</svg>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,147 +1,144 @@
|
|||
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 {
|
||||
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 (
|
||||
<div className="card bg-base-100/95 backdrop-blur-md shadow-lg">
|
||||
<div className="card-body">
|
||||
<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">
|
||||
<h2 className="text-2xl font-bold mb-4 text-primary">Event Details</h2>
|
||||
|
||||
<div className="space-y-6 mt-4">
|
||||
<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="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 className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||
<p className="text-sm">
|
||||
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.
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,254 +1,262 @@
|
|||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Icon } from '@iconify/react';
|
||||
import React, { useState, useEffect } 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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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 {
|
||||
onDataChange?: (data: any) => void;
|
||||
formData: EventRequestFormData;
|
||||
onDataChange: (data: Partial<EventRequestFormData>) => void;
|
||||
}
|
||||
|
||||
const PRSection: React.FC<PRSectionProps> = ({ onDataChange }) => {
|
||||
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
|
||||
const [selectedLogos, setSelectedLogos] = useState<string[]>([]);
|
||||
const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||
const [otherLogoFiles, setOtherLogoFiles] = useState<File[]>(formData.other_logos || []);
|
||||
|
||||
const flyerTypes = [
|
||||
{ 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' }
|
||||
];
|
||||
// Handle checkbox change for flyer types
|
||||
const handleFlyerTypeChange = (type: string) => {
|
||||
const updatedTypes = formData.flyer_type.includes(type)
|
||||
? formData.flyer_type.filter(t => t !== type)
|
||||
: [...formData.flyer_type, type];
|
||||
|
||||
const logoOptions = [
|
||||
{ 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 });
|
||||
}
|
||||
onDataChange({ flyer_type: updatedTypes });
|
||||
};
|
||||
|
||||
const handleLogoChange = (value: string) => {
|
||||
const newLogos = selectedLogos.includes(value)
|
||||
? selectedLogos.filter(logo => logo !== value)
|
||||
: [...selectedLogos, value];
|
||||
setSelectedLogos(newLogos);
|
||||
// Handle checkbox change for required logos
|
||||
const handleLogoChange = (logo: string) => {
|
||||
const updatedLogos = formData.required_logos.includes(logo)
|
||||
? formData.required_logos.filter(l => l !== logo)
|
||||
: [...formData.required_logos, logo];
|
||||
|
||||
if (onDataChange) {
|
||||
onDataChange({ required_logos: newLogos });
|
||||
}
|
||||
onDataChange({ required_logos: updatedLogos });
|
||||
};
|
||||
|
||||
const handleOtherLogosUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
onDataChange?.({ other_logos: files });
|
||||
toast.success(`${files.length} logo file(s) uploaded`);
|
||||
// Handle file upload for other logos
|
||||
const handleLogoFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const newFiles = Array.from(e.target.files);
|
||||
setOtherLogoFiles(newFiles);
|
||||
onDataChange({ other_logos: newFiles });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100/95 backdrop-blur-md shadow-lg">
|
||||
<div className="card-body">
|
||||
<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-6">
|
||||
<h2 className="text-2xl font-bold mb-4 text-primary">PR Materials</h2>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
<label className="form-control w-full">
|
||||
<div 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="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>
|
||||
))}
|
||||
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||
<p className="text-sm">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedTypes.includes('other') && (
|
||||
<div className="form-control w-full mt-2 ml-10">
|
||||
<input
|
||||
type="text"
|
||||
name="other_flyer_type"
|
||||
placeholder="Please specify other flyer type"
|
||||
className="input input-bordered w-full"
|
||||
onChange={(e) => onDataChange?.({ other_flyer_type: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
{/* Type of material needed */}
|
||||
<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">Type of material needed?</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<div className="space-y-2 mt-2">
|
||||
{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">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary mt-1"
|
||||
checked={formData.flyer_type.includes(type.value)}
|
||||
onChange={() => handleFlyerTypeChange(type.value)}
|
||||
/>
|
||||
<span>{type.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedTypes.length > 0 && (
|
||||
<>
|
||||
<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="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>
|
||||
Advertising Start Date
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
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>
|
||||
</>
|
||||
{/* Other flyer type input */}
|
||||
{formData.flyer_type.includes('other') && (
|
||||
<div className="mt-3 pl-7">
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered w-full"
|
||||
placeholder="Please specify other material needed"
|
||||
value={formData.other_flyer_type}
|
||||
onChange={(e) => onDataChange({ other_flyer_type: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -13,7 +13,22 @@ function convertLocalToUTC<T>(data: T): T {
|
|||
|
||||
const converted = { ...data };
|
||||
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();
|
||||
} else if (Array.isArray(value)) {
|
||||
(converted as any)[key] = value.map((item) => convertLocalToUTC(item));
|
||||
|
@ -60,7 +75,9 @@ export class Update {
|
|||
this.auth.setUpdating(true);
|
||||
const pb = this.auth.getPocketBase();
|
||||
const convertedData = convertLocalToUTC(data);
|
||||
const result = await pb.collection(collectionName).create<T>(convertedData);
|
||||
const result = await pb
|
||||
.collection(collectionName)
|
||||
.create<T>(convertedData);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`Failed to create record in ${collectionName}:`, err);
|
||||
|
@ -125,12 +142,12 @@ export class Update {
|
|||
this.auth.setUpdating(true);
|
||||
const pb = this.auth.getPocketBase();
|
||||
const convertedUpdates = convertLocalToUTC(updates);
|
||||
|
||||
|
||||
// If recordId is empty, create a new record instead of updating
|
||||
if (!recordId) {
|
||||
return this.create<T>(collectionName, convertedUpdates);
|
||||
}
|
||||
|
||||
|
||||
const result = await pb
|
||||
.collection(collectionName)
|
||||
.update<T>(recordId, convertedUpdates);
|
||||
|
|
Loading…
Reference in a new issue