From 97ce39728168a47f89970e37cc23910ed22b15b0 Mon Sep 17 00:00:00 2001 From: chark1es Date: Tue, 11 Mar 2025 05:55:52 -0700 Subject: [PATCH] improve event request form styling --- .../ASFundingSection.tsx | 741 +++++++------- .../EventDetailsSection.tsx | 186 +++- .../EventRequestForm.tsx | 192 ++-- .../EventRequestFormPreview.tsx | 11 +- .../InvoiceBuilder.tsx | 942 ++++++++++-------- .../Officer_EventRequestForm/PRSection.tsx | 340 +++++-- .../TAPFormSection.tsx | 307 +++++- 7 files changed, 1692 insertions(+), 1027 deletions(-) diff --git a/src/components/dashboard/Officer_EventRequestForm/ASFundingSection.tsx b/src/components/dashboard/Officer_EventRequestForm/ASFundingSection.tsx index 0769a71..4069910 100644 --- a/src/components/dashboard/Officer_EventRequestForm/ASFundingSection.tsx +++ b/src/components/dashboard/Officer_EventRequestForm/ASFundingSection.tsx @@ -3,418 +3,437 @@ import { motion } from 'framer-motion'; import toast from 'react-hot-toast'; import type { EventRequestFormData } from './EventRequestForm'; import InvoiceBuilder from './InvoiceBuilder'; -import type { InvoiceData } from './InvoiceBuilder'; +import type { InvoiceData, InvoiceItem } from './InvoiceBuilder'; import CustomAlert from '../universal/CustomAlert'; +import { Icon } from '@iconify/react'; + +// Enhanced animation variants with faster transitions +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.035, + when: "beforeChildren", + duration: 0.3, + ease: "easeOut" + } + } +}; -// Animation variants const itemVariants = { - hidden: { opacity: 0, y: 20 }, + hidden: { opacity: 0, y: 10 }, visible: { opacity: 1, y: 0, transition: { type: "spring", - stiffness: 300, - damping: 24 + stiffness: 500, + damping: 25, + mass: 0.8, + duration: 0.25 } } }; +// Input field hover animation +const inputHoverVariants = { + hover: { + scale: 1.01, + boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)", + transition: { duration: 0.15 } + } +}; + +// Button animation +const buttonVariants = { + hover: { + scale: 1.03, + transition: { duration: 0.15, ease: "easeOut" } + }, + tap: { + scale: 0.97, + transition: { duration: 0.1 } + } +}; + +// Toggle animation +const toggleVariants = { + checked: { backgroundColor: "rgba(var(--p), 0.2)" }, + unchecked: { backgroundColor: "rgba(0, 0, 0, 0.05)" }, + hover: { scale: 1.01, transition: { duration: 0.15 } } +}; + interface ASFundingSectionProps { formData: EventRequestFormData; onDataChange: (data: Partial) => void; } const ASFundingSection: React.FC = ({ formData, onDataChange }) => { - const [invoiceFile, setInvoiceFile] = useState(formData.invoice); const [invoiceFiles, setInvoiceFiles] = useState(formData.invoice_files || []); - const [useJsonInput, setUseJsonInput] = useState(false); - const [jsonInput, setJsonInput] = useState(''); - const [jsonError, setJsonError] = useState(''); - const [showExample, setShowExample] = useState(false); + const [jsonInput, setJsonInput] = useState(''); + const [jsonError, setJsonError] = useState(''); + const [showJsonInput, setShowJsonInput] = useState(false); + const [isDragging, setIsDragging] = useState(false); - // Example JSON for the user to reference - const jsonExample = { - items: [ - { - item: "Chicken Plate", - quantity: 10, - unit_price: 12.99 - }, - { - item: "Vegetarian Plate", - quantity: 5, - unit_price: 11.99 - }, - { - item: "Bottled Water", - quantity: 15, - unit_price: 1.50 - } - ], - tax: 10.14, - tip: 15.00, - vendor: "L&L Hawaiian BBQ" - }; - - // Handle single invoice file upload (for backward compatibility) + // Handle invoice file upload const handleInvoiceFileChange = (e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { - const file = e.target.files[0]; - setInvoiceFile(file); - onDataChange({ invoice: file }); + const newFiles = Array.from(e.target.files) as File[]; + setInvoiceFiles(newFiles); + onDataChange({ invoice_files: newFiles }); } }; - // Handle multiple invoice files upload - const handleMultipleInvoiceFilesChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - const files = Array.from(e.target.files) as File[]; - - // Check file sizes - const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB limit per file - const oversizedFiles = files.filter(file => file.size > MAX_FILE_SIZE); - - if (oversizedFiles.length > 0) { - toast.error(`Some files exceed the 10MB size limit: ${oversizedFiles.map(f => f.name).join(', ')}`); - return; - } - - // Update state with new files - const updatedFiles = [...invoiceFiles, ...files]; - setInvoiceFiles(updatedFiles); - onDataChange({ invoice_files: updatedFiles }); - - // 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] }); - } - - toast.success(`Added ${files.length} file${files.length > 1 ? 's' : ''} successfully`); - } - }; - - // Remove an invoice file - const handleRemoveInvoiceFile = (index: number) => { - const updatedFiles = [...invoiceFiles]; - const removedFileName = updatedFiles[index].name; - updatedFiles.splice(index, 1); - setInvoiceFiles(updatedFiles); - onDataChange({ invoice_files: updatedFiles }); - - // Update the main invoice file if needed - if (invoiceFile && invoiceFile.name === removedFileName) { - const newMainFile = updatedFiles.length > 0 ? updatedFiles[0] : null; - setInvoiceFile(newMainFile); - onDataChange({ invoice: newMainFile }); - } - - toast.success(`Removed ${removedFileName}`); - }; - - // 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 }); - }; - // Handle JSON input change const handleJsonInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setJsonInput(value); - - // Validate JSON as user types - if (value.trim()) { - try { - JSON.parse(value); - setJsonError(''); - } catch (err) { - setJsonError('Invalid JSON format. Please check your syntax.'); - } - } else { - setJsonError(''); - } + setJsonInput(e.target.value); + setJsonError(''); }; // Show JSON example const showJsonExample = () => { - // Toggle example visibility - setShowExample(!showExample); - - // If showing example, populate the textarea with the example JSON - if (!showExample) { - setJsonInput(JSON.stringify(jsonExample, null, 2)); - } + const example = { + vendor: "Example Restaurant", + items: [ + { + id: "item-1", + description: "Burger", + quantity: 2, + unitPrice: 12.99, + amount: 25.98 + }, + { + id: "item-2", + description: "Fries", + quantity: 2, + unitPrice: 4.99, + amount: 9.98 + } + ], + subtotal: 35.96, + taxRate: 9.0, + taxAmount: 3.24, + tipPercentage: 13.9, + tipAmount: 5.00, + total: 44.20 + }; + setJsonInput(JSON.stringify(example, null, 2)); }; // Validate and apply JSON const validateAndApplyJson = () => { try { - // Parse the JSON input - const parsedJson = JSON.parse(jsonInput); - - // Validate the structure - let validationError = ''; - - // Check for required fields - if (!parsedJson.items || !Array.isArray(parsedJson.items)) { - validationError = 'Missing or invalid "items" array.'; - } else if (parsedJson.items.length === 0) { - validationError = 'The "items" array cannot be empty.'; - } else { - // Check each item in the array - for (let i = 0; i < parsedJson.items.length; i++) { - const item = parsedJson.items[i]; - if (!item.item || typeof item.item !== 'string') { - validationError = `Item #${i + 1} is missing a valid "item" name.`; - break; - } - if (typeof item.quantity !== 'number' || item.quantity <= 0) { - validationError = `Item #${i + 1} is missing a valid "quantity" (must be a positive number).`; - break; - } - if (typeof item.unit_price !== 'number' || item.unit_price < 0) { - validationError = `Item #${i + 1} is missing a valid "unit_price" (must be a non-negative number).`; - break; - } - } - } - - // Check other required fields - if (!validationError) { - if (typeof parsedJson.tax !== 'number') { - validationError = 'Missing or invalid "tax" amount (must be a number).'; - } else if (typeof parsedJson.tip !== 'number') { - validationError = 'Missing or invalid "tip" amount (must be a number).'; - } else if (!parsedJson.vendor || typeof parsedJson.vendor !== 'string') { - validationError = 'Missing or invalid "vendor" name.'; - } - } - - if (validationError) { - setJsonError(validationError); + if (!jsonInput.trim()) { + setJsonError('JSON input is empty'); return; } - // Calculate subtotal and total - const subtotal = parsedJson.items.reduce((sum: number, item: any) => sum + (item.quantity * item.unit_price), 0); - const total = subtotal + parsedJson.tax + parsedJson.tip; + const data = JSON.parse(jsonInput); - // Convert the JSON to the format expected by InvoiceData - const invoiceData: InvoiceData = { - items: parsedJson.items.map((item: any, index: number) => ({ - id: `item-${index + 1}`, - description: item.item, + // Validate structure + if (!data.vendor) { + setJsonError('Vendor field is required'); + return; + } + + if (!Array.isArray(data.items) || data.items.length === 0) { + setJsonError('Items array is required and must contain at least one item'); + return; + } + + // Validate items + for (const item of data.items) { + if (!item.description || typeof item.unitPrice !== 'number' || typeof item.quantity !== 'number') { + setJsonError('Each item must have description, unitPrice, and quantity fields'); + return; + } + } + + // Validate tax, tip, and total + if (typeof data.taxAmount !== 'number') { + setJsonError('Tax amount must be a number'); + return; + } + + if (typeof data.tipAmount !== 'number') { + setJsonError('Tip amount must be a number'); + return; + } + + if (typeof data.total !== 'number') { + setJsonError('Total is required and must be a number'); + return; + } + + // Create itemized invoice string for Pocketbase + const itemizedInvoice = JSON.stringify({ + vendor: data.vendor, + items: data.items.map((item: InvoiceItem) => ({ + item: item.description, quantity: item.quantity, - unitPrice: item.unit_price, - amount: item.quantity * item.unit_price + unit_price: item.unitPrice, + amount: item.amount })), - subtotal: subtotal, - taxRate: subtotal ? (parsedJson.tax / subtotal) * 100 : 0, - taxAmount: parsedJson.tax, - tipPercentage: subtotal ? (parsedJson.tip / subtotal) * 100 : 0, - tipAmount: parsedJson.tip, - total: total, - vendor: parsedJson.vendor - }; + subtotal: data.subtotal, + tax: data.taxAmount, + tip: data.tipAmount, + total: data.total + }, null, 2); - // Update the form data - handleInvoiceDataChange(invoiceData); + // Apply the JSON data to the form + onDataChange({ + invoiceData: data, + itemized_invoice: itemizedInvoice, + as_funding_required: true + }); - // Update the itemized_invoice field with the complete JSON including calculated total - const completeJson = { - ...parsedJson, - subtotal: subtotal, - total: total - }; - onDataChange({ itemized_invoice: JSON.stringify(completeJson, null, 2) }); - - // Show success message - toast.success('JSON invoice data applied successfully!'); - - // Optionally, switch back to the invoice builder view to show the applied data - setUseJsonInput(false); - } catch (err) { - setJsonError('Failed to parse JSON. Please check your syntax.'); + toast.success('Invoice data applied successfully'); + setShowJsonInput(false); + } catch (error) { + setJsonError('Invalid JSON format: ' + (error as Error).message); } }; + // Handle drag events for file upload + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const newFiles = Array.from(e.dataTransfer.files) as File[]; + setInvoiceFiles(newFiles); + onDataChange({ invoice_files: newFiles }); + } + }; + + // Handle invoice data change + const handleInvoiceDataChange = (data: InvoiceData) => { + // Create itemized invoice string for Pocketbase + const itemizedInvoice = JSON.stringify({ + vendor: data.vendor, + items: data.items.map((item: InvoiceItem) => ({ + item: item.description, + quantity: item.quantity, + unit_price: item.unitPrice, + amount: item.amount + })), + subtotal: data.subtotal, + tax: data.taxAmount, + tip: data.tipAmount, + total: data.total + }, null, 2); + + onDataChange({ + invoiceData: data, + itemized_invoice: itemizedInvoice + }); + }; + return ( -
-

AS Funding Information

- - - - {/* Invoice Builder Instructions */} - -

How to Use the Invoice Builder

-
    -
  1. Enter the vendor/restaurant name in the field provided.
  2. -
  3. Add each item from your invoice by filling in the description, quantity, and unit price, then click "Add Item".
  4. -
  5. The subtotal, tax, and tip will be calculated automatically based on the tax rate and tip percentage.
  6. -
  7. You can adjust the tax rate (default is 7.75% for San Diego) and tip percentage as needed.
  8. -
  9. Remove items by clicking the "X" button next to each item.
  10. -
  11. Upload your actual invoice file (receipt, screenshot, etc.) using the file upload below.
  12. -
-
- - {/* JSON Invoice Paste Option */} - -
-

Paste JSON Invoice

-
- -
-
- - {useJsonInput && ( - <> -
- - - {jsonError && ( - - )} -
- -
- -
- - - - )} -
- - {/* Invoice Builder */} - {!useJsonInput && ( - - )} - - {/* Invoice file upload */} - - - - - {invoiceFiles.length > 0 && ( -
-

Uploaded files:

-
- {invoiceFiles.map((file, index) => ( -
- {file.name} - -
- ))} -
-
- )} - -

- 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) + + +

+ AS Funding Details +

+

+ Please provide the necessary information for your Associated Students funding request.

- -
+ + + + + {/* Invoice Upload Section */} + +

Invoice Information

+

Upload your invoice files or create an itemized invoice below.

+ + document.getElementById('invoice-files')?.click()} + > + + +
+ + + + + {invoiceFiles.length > 0 ? ( + <> +

{invoiceFiles.length} file(s) selected:

+
+
    + {invoiceFiles.map((file, index) => ( +
  • {file.name}
  • + ))} +
+
+

Click or drag to replace

+ + ) : ( + <> +

Drop your invoice files here or click to browse

+

Supports PDF, JPG, JPEG, PNG

+ + )} +
+
+
+ + {/* JSON/Builder Toggle */} + +
+

Invoice Details

+ + + setShowJsonInput(false)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + Invoice Builder + + setShowJsonInput(true)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + Paste JSON + + +
+ + {showJsonInput ? ( + +
+ + + + Show Example + +
+ + + + {jsonError && ( +
+ + {jsonError} +
+ )} + +
+ + + Apply JSON + +
+
+ ) : ( + + + + )} +
+ ); }; diff --git a/src/components/dashboard/Officer_EventRequestForm/EventDetailsSection.tsx b/src/components/dashboard/Officer_EventRequestForm/EventDetailsSection.tsx index c525463..69f8674 100644 --- a/src/components/dashboard/Officer_EventRequestForm/EventDetailsSection.tsx +++ b/src/components/dashboard/Officer_EventRequestForm/EventDetailsSection.tsx @@ -2,8 +2,20 @@ import React from 'react'; import { motion } from 'framer-motion'; import type { EventRequestFormData } from './EventRequestForm'; import type { EventRequest } from '../../../schemas/pocketbase'; +import CustomAlert from '../universal/CustomAlert'; + +// Enhanced animation variants +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1, + when: "beforeChildren" + } + } +}; -// Animation variants const itemVariants = { hidden: { opacity: 0, y: 20 }, visible: { @@ -17,6 +29,15 @@ const itemVariants = { } }; +// Input field hover animation +const inputHoverVariants = { + hover: { + scale: 1.01, + boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)", + transition: { duration: 0.2 } + } +}; + interface EventDetailsSectionProps { formData: EventRequestFormData; onDataChange: (data: Partial) => void; @@ -24,101 +45,156 @@ interface EventDetailsSectionProps { const EventDetailsSection: React.FC = ({ formData, onDataChange }) => { return ( -
-

Event Details

+ + +

+ Event Details +

+
-
-

- 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. -

-
+ + + {/* Event Name */} - + - onDataChange({ name: e.target.value })} placeholder="Enter event name" required + whileHover="hover" + variants={inputHoverVariants} /> {/* Event Description */} - + -