idk i forgot
This commit is contained in:
parent
e16b08dbe6
commit
d4fe0bf2b0
7 changed files with 1260 additions and 4 deletions
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"workbench.colorCustomizations": {
|
||||||
|
"activityBar.background": "#221489",
|
||||||
|
"titleBar.activeBackground": "#301DC0",
|
||||||
|
"titleBar.activeForeground": "#F9F9FE"
|
||||||
|
}
|
||||||
|
}
|
|
@ -111,7 +111,7 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
return 'badge-error';
|
return 'badge-error';
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return 'badge-warning';
|
return 'badge-warning';
|
||||||
case 'in progress':
|
case 'submitted':
|
||||||
return 'badge-info';
|
return 'badge-info';
|
||||||
default:
|
default:
|
||||||
return 'badge-warning';
|
return 'badge-warning';
|
||||||
|
@ -255,7 +255,7 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
<td>{formatDate(request.created)}</td>
|
<td>{formatDate(request.created)}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`badge ${getStatusBadge(request.status)} badge-sm`}>
|
<span className={`badge ${getStatusBadge(request.status)} badge-sm`}>
|
||||||
{request.status || 'Pending'}
|
{request.status || 'Submitted'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
145
src/components/dashboard/Officer_EventRequestManagement.astro
Normal file
145
src/components/dashboard/Officer_EventRequestManagement.astro
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
---
|
||||||
|
import { Authentication } from "../../scripts/pocketbase/Authentication";
|
||||||
|
import { Get } from "../../scripts/pocketbase/Get";
|
||||||
|
import { Toaster } from "react-hot-toast";
|
||||||
|
import EventRequestManagementTable from "./Officer_EventRequestManagement/EventRequestManagementTable";
|
||||||
|
|
||||||
|
// Get instances
|
||||||
|
const get = Get.getInstance();
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
|
||||||
|
// Define the EventRequest interface
|
||||||
|
interface EventRequest {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
start_date_time: string;
|
||||||
|
end_date_time: string;
|
||||||
|
event_description: string;
|
||||||
|
flyers_needed: boolean;
|
||||||
|
photography_needed: boolean;
|
||||||
|
as_funding_required: boolean;
|
||||||
|
food_drinks_being_served: boolean;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
status: string;
|
||||||
|
requested_user: string;
|
||||||
|
requested_user_expand?: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
expand?: {
|
||||||
|
requested_user?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
feedback?: string;
|
||||||
|
[key: string]: any; // For other optional properties
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize variables for all event requests
|
||||||
|
let allEventRequests: EventRequest[] = [];
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
// Fetch all event requests if authenticated
|
||||||
|
if (auth.isAuthenticated()) {
|
||||||
|
try {
|
||||||
|
// Expand the requested_user field to get user details
|
||||||
|
allEventRequests = await get.getAll<EventRequest>(
|
||||||
|
"event_request",
|
||||||
|
"",
|
||||||
|
"-created",
|
||||||
|
{
|
||||||
|
fields: ["*"],
|
||||||
|
expand: ["requested_user"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch event requests:", err);
|
||||||
|
error = "Failed to load event requests. Please try again later.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="w-full max-w-7xl mx-auto py-8 px-4">
|
||||||
|
<style>
|
||||||
|
.event-table-container {
|
||||||
|
min-height: 600px;
|
||||||
|
height: auto !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
.event-table-container table {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
.event-table-container .overflow-x-auto {
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-2">Event Request Management</h1>
|
||||||
|
<p class="text-gray-300 mb-4">
|
||||||
|
Review and manage event requests submitted by officers. Update status,
|
||||||
|
provide feedback, and coordinate with the team.
|
||||||
|
</p>
|
||||||
|
<div class="bg-base-300/50 p-4 rounded-lg text-sm text-gray-300">
|
||||||
|
<p class="font-medium mb-2">As an executive officer, you can:</p>
|
||||||
|
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||||
|
<li>View all submitted event requests</li>
|
||||||
|
<li>Update the status of requests (Pending, Completed, Declined)</li>
|
||||||
|
<li>Add comments or feedback for the requesting officer</li>
|
||||||
|
<li>Filter and sort requests by various criteria</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
error && (
|
||||||
|
<div class="alert alert-error mb-6">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6 stroke-current shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!error && (
|
||||||
|
<div class="bg-base-200 rounded-lg shadow-xl min-h-[600px] event-table-container">
|
||||||
|
<div class="p-4 md:p-6 h-auto">
|
||||||
|
<EventRequestManagementTable
|
||||||
|
client:load
|
||||||
|
eventRequests={allEventRequests}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Toast container for notifications -->
|
||||||
|
<Toaster client:load position="bottom-right" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Refresh the page when the user navigates back to it
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -0,0 +1,535 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
// Define the EventRequest interface
|
||||||
|
interface EventRequest {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
start_date_time: string;
|
||||||
|
end_date_time: string;
|
||||||
|
event_description: string;
|
||||||
|
flyers_needed: boolean;
|
||||||
|
photography_needed: boolean;
|
||||||
|
as_funding_required: boolean;
|
||||||
|
food_drinks_being_served: boolean;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
status: string;
|
||||||
|
requested_user: string;
|
||||||
|
requested_user_expand?: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
flyer_type?: string[];
|
||||||
|
other_flyer_type?: string;
|
||||||
|
flyer_advertising_start_date?: string;
|
||||||
|
flyer_additional_requests?: string;
|
||||||
|
required_logos?: string[];
|
||||||
|
advertising_format?: string;
|
||||||
|
will_or_have_room_booking?: boolean;
|
||||||
|
expected_attendance?: number;
|
||||||
|
itemized_invoice?: string;
|
||||||
|
invoice_data?: string | any;
|
||||||
|
feedback?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventRequestDetailsProps {
|
||||||
|
request: EventRequest;
|
||||||
|
onClose: () => void;
|
||||||
|
onStatusChange: (id: string, status: string) => Promise<void>;
|
||||||
|
onFeedbackChange: (id: string, feedback: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate component for AS Funding tab to isolate any issues
|
||||||
|
const ASFundingTab: React.FC<{ request: EventRequest }> = ({ request }) => {
|
||||||
|
if (!request.as_funding_required) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">AS Funding Required</h4>
|
||||||
|
<p>No</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process invoice data for display
|
||||||
|
let invoiceData = request.invoice_data;
|
||||||
|
|
||||||
|
// If invoice_data is not available, try to parse itemized_invoice
|
||||||
|
if (!invoiceData && request.itemized_invoice) {
|
||||||
|
try {
|
||||||
|
if (typeof request.itemized_invoice === 'string') {
|
||||||
|
invoiceData = JSON.parse(request.itemized_invoice);
|
||||||
|
} else if (typeof request.itemized_invoice === 'object') {
|
||||||
|
invoiceData = request.itemized_invoice;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse itemized_invoice:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">AS Funding Required</h4>
|
||||||
|
<p>Yes</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{request.food_drinks_being_served && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Food/Drinks Being Served</h4>
|
||||||
|
<p>Yes</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Invoice Data</h4>
|
||||||
|
<InvoiceTable invoiceData={invoiceData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Separate component for invoice table
|
||||||
|
const InvoiceTable: React.FC<{ invoiceData: any }> = ({ invoiceData }) => {
|
||||||
|
try {
|
||||||
|
// Parse invoice data if it's a string
|
||||||
|
let parsedInvoice = null;
|
||||||
|
|
||||||
|
if (typeof invoiceData === 'string') {
|
||||||
|
try {
|
||||||
|
parsedInvoice = JSON.parse(invoiceData);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse invoice data string:', e);
|
||||||
|
return (
|
||||||
|
<div className="alert alert-warning">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" 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>Invalid invoice data format.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (typeof invoiceData === 'object' && invoiceData !== null) {
|
||||||
|
parsedInvoice = invoiceData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have valid invoice data
|
||||||
|
if (!parsedInvoice || typeof parsedInvoice !== 'object') {
|
||||||
|
return (
|
||||||
|
<div className="alert alert-info">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-6 h-6"><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>
|
||||||
|
<span>No structured invoice data available.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract items array
|
||||||
|
let items = [];
|
||||||
|
if (parsedInvoice.items && Array.isArray(parsedInvoice.items)) {
|
||||||
|
items = parsedInvoice.items;
|
||||||
|
} else if (Array.isArray(parsedInvoice)) {
|
||||||
|
items = parsedInvoice;
|
||||||
|
} else if (parsedInvoice.items && typeof parsedInvoice.items === 'object') {
|
||||||
|
items = [parsedInvoice.items]; // Wrap single item in array
|
||||||
|
} else {
|
||||||
|
// Try to find any array in the object
|
||||||
|
for (const key in parsedInvoice) {
|
||||||
|
if (Array.isArray(parsedInvoice[key])) {
|
||||||
|
items = parsedInvoice[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we still don't have items, check if the object itself looks like an item
|
||||||
|
if (items.length === 0 && parsedInvoice.item || parsedInvoice.description || parsedInvoice.name) {
|
||||||
|
items = [parsedInvoice];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we still don't have items, show a message
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="alert alert-info">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-6 h-6"><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>
|
||||||
|
<span>No invoice items found in the data.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate subtotal from items
|
||||||
|
const subtotal = items.reduce((sum: number, item: any) => {
|
||||||
|
const quantity = parseFloat(item?.quantity || 1);
|
||||||
|
const price = parseFloat(item?.unit_price || item?.price || 0);
|
||||||
|
return sum + (quantity * price);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Get tax, tip and total
|
||||||
|
const tax = parseFloat(parsedInvoice.tax || parsedInvoice.taxAmount || 0);
|
||||||
|
const tip = parseFloat(parsedInvoice.tip || parsedInvoice.tipAmount || 0);
|
||||||
|
const total = parseFloat(parsedInvoice.total || 0) || (subtotal + tax + tip);
|
||||||
|
|
||||||
|
// Render the invoice table
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table table-zebra w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<th>Quantity</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item: any, index: number) => {
|
||||||
|
// Ensure we're not trying to render an object directly
|
||||||
|
const itemName = typeof item?.item === 'object'
|
||||||
|
? JSON.stringify(item.item)
|
||||||
|
: (item?.item || item?.description || item?.name || 'N/A');
|
||||||
|
|
||||||
|
const quantity = parseFloat(item?.quantity || 1);
|
||||||
|
const unitPrice = parseFloat(item?.unit_price || item?.price || 0);
|
||||||
|
const itemTotal = quantity * unitPrice;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={index}>
|
||||||
|
<td>{itemName}</td>
|
||||||
|
<td>{quantity}</td>
|
||||||
|
<td>${unitPrice.toFixed(2)}</td>
|
||||||
|
<td>${itemTotal.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-right font-medium">Subtotal:</td>
|
||||||
|
<td>${subtotal.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
{tax > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-right font-medium">Tax:</td>
|
||||||
|
<td>${tax.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{tip > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-right font-medium">Tip:</td>
|
||||||
|
<td>${tip.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-right font-bold">Total:</td>
|
||||||
|
<td className="font-bold">${total.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{parsedInvoice.vendor && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<span className="font-medium">Vendor:</span> {parsedInvoice.vendor}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rendering invoice table:', error);
|
||||||
|
return (
|
||||||
|
<div className="alert alert-error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" 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>An unexpected error occurred while processing the invoice.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const EventRequestDetails: React.FC<EventRequestDetailsProps> = ({
|
||||||
|
request,
|
||||||
|
onClose,
|
||||||
|
onStatusChange,
|
||||||
|
onFeedbackChange
|
||||||
|
}) => {
|
||||||
|
const [feedback, setFeedback] = useState<string>(request.feedback || '');
|
||||||
|
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'details' | 'pr' | 'funding'>('details');
|
||||||
|
const [status, setStatus] = useState(request.status);
|
||||||
|
const [isStatusChanging, setIsStatusChanging] = useState(false);
|
||||||
|
|
||||||
|
// Format date for display
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return 'Not specified';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get status badge class based on status
|
||||||
|
const getStatusBadge = (status?: string) => {
|
||||||
|
if (!status) return 'badge-warning';
|
||||||
|
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case 'completed':
|
||||||
|
return 'badge-success';
|
||||||
|
case 'declined':
|
||||||
|
return 'badge-error';
|
||||||
|
case 'pending':
|
||||||
|
return 'badge-warning';
|
||||||
|
default:
|
||||||
|
return 'badge-warning';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle saving feedback
|
||||||
|
const handleSaveFeedback = async () => {
|
||||||
|
if (feedback === request.feedback) {
|
||||||
|
toast('No changes to save', { icon: 'ℹ️' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
const success = await onFeedbackChange(request.id, feedback);
|
||||||
|
setIsSaving(false);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast.success('Feedback saved successfully');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle status change
|
||||||
|
const handleStatusChange = async (status: string) => {
|
||||||
|
setIsStatusChanging(true);
|
||||||
|
await onStatusChange(request.id, status);
|
||||||
|
setStatus(status);
|
||||||
|
setIsStatusChanging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4 overflow-y-auto">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="bg-base-200 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-base-300 p-4 flex justify-between items-center">
|
||||||
|
<h3 className="text-xl font-bold">{request.name}</h3>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-circle"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" 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>
|
||||||
|
|
||||||
|
{/* Status and controls */}
|
||||||
|
<div className="bg-base-300/50 p-4 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-400">Status:</span>
|
||||||
|
<span className={`badge ${getStatusBadge(status)}`}>
|
||||||
|
{status || 'Pending'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Requested by: <span className="text-white">{request.requested_user_expand?.name || request.requested_user || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="dropdown dropdown-end">
|
||||||
|
<label tabIndex={0} className="btn btn-sm">
|
||||||
|
Update Status
|
||||||
|
</label>
|
||||||
|
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
|
||||||
|
<li><a onClick={() => handleStatusChange('pending')}>Pending</a></li>
|
||||||
|
<li><a onClick={() => handleStatusChange('completed')}>Completed</a></li>
|
||||||
|
<li><a onClick={() => handleStatusChange('declined')}>Declined</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="tabs tabs-boxed bg-base-300/30 px-4 pt-4">
|
||||||
|
<a
|
||||||
|
className={`tab ${activeTab === 'details' ? 'tab-active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('details')}
|
||||||
|
>
|
||||||
|
Event Details
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className={`tab ${activeTab === 'pr' ? 'tab-active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('pr')}
|
||||||
|
>
|
||||||
|
PR Materials
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className={`tab ${activeTab === 'funding' ? 'tab-active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('funding')}
|
||||||
|
>
|
||||||
|
AS Funding
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 overflow-y-auto flex-grow">
|
||||||
|
{/* Event Details Tab */}
|
||||||
|
{activeTab === 'details' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Event Name</h4>
|
||||||
|
<p className="text-lg">{request.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Location</h4>
|
||||||
|
<p>{request.location || 'Not specified'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Start Date & Time</h4>
|
||||||
|
<p>{formatDate(request.start_date_time)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">End Date & Time</h4>
|
||||||
|
<p>{formatDate(request.end_date_time)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Expected Attendance</h4>
|
||||||
|
<p>{request.expected_attendance || 'Not specified'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Event Description</h4>
|
||||||
|
<p className="whitespace-pre-line">{request.event_description || 'No description provided'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Room Booking</h4>
|
||||||
|
<p>{request.will_or_have_room_booking ? 'Yes' : 'No'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Food/Drinks Served</h4>
|
||||||
|
<p>{request.food_drinks_being_served ? 'Yes' : 'No'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Submission Date</h4>
|
||||||
|
<p>{formatDate(request.created)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PR Materials Tab */}
|
||||||
|
{activeTab === 'pr' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Flyers Needed</h4>
|
||||||
|
<p>{request.flyers_needed ? 'Yes' : 'No'}</p>
|
||||||
|
</div>
|
||||||
|
{request.flyers_needed && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Flyer Types</h4>
|
||||||
|
<ul className="list-disc list-inside">
|
||||||
|
{request.flyer_type?.map((type, index) => (
|
||||||
|
<li key={index}>{type}</li>
|
||||||
|
))}
|
||||||
|
{request.other_flyer_type && <li>{request.other_flyer_type}</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Advertising Start Date</h4>
|
||||||
|
<p>{formatDate(request.flyer_advertising_start_date || '')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Advertising Format</h4>
|
||||||
|
<p>{request.advertising_format || 'Not specified'}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Photography Needed</h4>
|
||||||
|
<p>{request.photography_needed ? 'Yes' : 'No'}</p>
|
||||||
|
</div>
|
||||||
|
{request.flyers_needed && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Required Logos</h4>
|
||||||
|
<ul className="list-disc list-inside">
|
||||||
|
{request.required_logos?.map((logo, index) => (
|
||||||
|
<li key={index}>{logo}</li>
|
||||||
|
))}
|
||||||
|
{(!request.required_logos || request.required_logos.length === 0) &&
|
||||||
|
<li>No specific logos required</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-1">Additional Requests</h4>
|
||||||
|
<p className="whitespace-pre-line">{request.flyer_additional_requests || 'None'}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AS Funding Tab */}
|
||||||
|
{activeTab === 'funding' && (
|
||||||
|
<ASFundingTab request={request} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback section */}
|
||||||
|
<div className="p-4 border-t border-base-300">
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-2">Feedback for Requester</h4>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-bordered w-full"
|
||||||
|
placeholder="Add feedback or notes for the event requester..."
|
||||||
|
rows={3}
|
||||||
|
value={feedback}
|
||||||
|
onChange={(e) => setFeedback(e.target.value)}
|
||||||
|
></textarea>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={handleSaveFeedback}
|
||||||
|
disabled={isSaving || feedback === request.feedback}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-xs"></span>
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save Feedback'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EventRequestDetails;
|
|
@ -0,0 +1,560 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
|
import { Update } from '../../../scripts/pocketbase/Update';
|
||||||
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import EventRequestDetails from './EventRequestDetails';
|
||||||
|
|
||||||
|
// Define the EventRequest interface
|
||||||
|
interface EventRequest {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
start_date_time: string;
|
||||||
|
end_date_time: string;
|
||||||
|
event_description: string;
|
||||||
|
flyers_needed: boolean;
|
||||||
|
photography_needed: boolean;
|
||||||
|
as_funding_required: boolean;
|
||||||
|
food_drinks_being_served: boolean;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
status: string;
|
||||||
|
requested_user: string;
|
||||||
|
requested_user_expand?: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
expand?: {
|
||||||
|
requested_user?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
flyer_type?: string[];
|
||||||
|
other_flyer_type?: string;
|
||||||
|
flyer_advertising_start_date?: string;
|
||||||
|
flyer_additional_requests?: string;
|
||||||
|
required_logos?: string[];
|
||||||
|
advertising_format?: string;
|
||||||
|
will_or_have_room_booking?: boolean;
|
||||||
|
expected_attendance?: number;
|
||||||
|
itemized_invoice?: string;
|
||||||
|
invoice_data?: any;
|
||||||
|
feedback?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventRequestManagementTableProps {
|
||||||
|
eventRequests: EventRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventRequestManagementTable: React.FC<EventRequestManagementTableProps> = ({ eventRequests: initialEventRequests }) => {
|
||||||
|
const [eventRequests, setEventRequests] = useState<EventRequest[]>(initialEventRequests);
|
||||||
|
const [filteredRequests, setFilteredRequests] = useState<EventRequest[]>(initialEventRequests);
|
||||||
|
const [selectedRequest, setSelectedRequest] = useState<EventRequest | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||||
|
const [sortField, setSortField] = useState<string>('created');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
|
// Refresh event requests
|
||||||
|
const refreshEventRequests = async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
const refreshToast = toast.loading('Refreshing event requests...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const get = Get.getInstance();
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
|
||||||
|
if (!auth.isAuthenticated()) {
|
||||||
|
toast.error('You must be logged in to refresh event requests', { id: refreshToast });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRequests = await get.getAll<EventRequest>(
|
||||||
|
'event_request',
|
||||||
|
'',
|
||||||
|
'-created',
|
||||||
|
{
|
||||||
|
fields: ['*'],
|
||||||
|
expand: ['requested_user']
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setEventRequests(updatedRequests);
|
||||||
|
applyFilters(updatedRequests);
|
||||||
|
toast.success('Event requests refreshed successfully', { id: refreshToast });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to refresh event requests:', err);
|
||||||
|
toast.error('Failed to refresh event requests. Please try again.', { id: refreshToast });
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply filters and sorting
|
||||||
|
const applyFilters = (requests = eventRequests) => {
|
||||||
|
let filtered = [...requests];
|
||||||
|
|
||||||
|
// Apply status filter
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
filtered = filtered.filter(request =>
|
||||||
|
request.status?.toLowerCase() === statusFilter.toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
filtered = filtered.filter(request =>
|
||||||
|
request.name.toLowerCase().includes(term) ||
|
||||||
|
request.location.toLowerCase().includes(term) ||
|
||||||
|
request.event_description.toLowerCase().includes(term) ||
|
||||||
|
request.expand?.requested_user?.name?.toLowerCase().includes(term) ||
|
||||||
|
request.expand?.requested_user?.email?.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let aValue: any = a[sortField as keyof EventRequest];
|
||||||
|
let bValue: any = b[sortField as keyof EventRequest];
|
||||||
|
|
||||||
|
// Handle special cases
|
||||||
|
if (sortField === 'requested_user') {
|
||||||
|
aValue = a.expand?.requested_user?.name || '';
|
||||||
|
bValue = b.expand?.requested_user?.name || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle date fields
|
||||||
|
if (sortField === 'created' || sortField === 'updated' ||
|
||||||
|
sortField === 'start_date_time' || sortField === 'end_date_time') {
|
||||||
|
aValue = new Date(aValue || '').getTime();
|
||||||
|
bValue = new Date(bValue || '').getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare values based on sort direction
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return aValue > bValue ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilteredRequests(filtered);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update event request status
|
||||||
|
const updateEventRequestStatus = async (id: string, status: string) => {
|
||||||
|
const updateToast = toast.loading(`Updating status to ${status}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const update = Update.getInstance();
|
||||||
|
const result = await update.updateField('event_request', id, 'status', status);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setEventRequests(prev =>
|
||||||
|
prev.map(request =>
|
||||||
|
request.id === id ? { ...request, status } : request
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setFilteredRequests(prev =>
|
||||||
|
prev.map(request =>
|
||||||
|
request.id === id ? { ...request, status } : request
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update selected request if open
|
||||||
|
if (selectedRequest && selectedRequest.id === id) {
|
||||||
|
setSelectedRequest({ ...selectedRequest, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Status updated to ${status}`, { id: updateToast });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update event request status:', err);
|
||||||
|
toast.error('Failed to update status. Please try again.', { id: updateToast });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add feedback to event request
|
||||||
|
const addFeedback = async (id: string, feedback: string) => {
|
||||||
|
const feedbackToast = toast.loading('Saving feedback...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const update = Update.getInstance();
|
||||||
|
const result = await update.updateField('event_request', id, 'feedback', feedback);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setEventRequests(prev =>
|
||||||
|
prev.map(request =>
|
||||||
|
request.id === id ? { ...request, feedback } : request
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setFilteredRequests(prev =>
|
||||||
|
prev.map(request =>
|
||||||
|
request.id === id ? { ...request, feedback } : request
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success('Feedback saved successfully', { id: feedbackToast });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save feedback:', err);
|
||||||
|
toast.error('Failed to save feedback. Please try again.', { id: feedbackToast });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format date for display
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return 'Not specified';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get status badge class based on status
|
||||||
|
const getStatusBadge = (status?: string) => {
|
||||||
|
if (!status) return 'badge-warning';
|
||||||
|
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case 'completed':
|
||||||
|
return 'badge-success';
|
||||||
|
case 'declined':
|
||||||
|
return 'badge-error';
|
||||||
|
case 'pending':
|
||||||
|
return 'badge-warning';
|
||||||
|
default:
|
||||||
|
return 'badge-warning';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open modal with event request details
|
||||||
|
const openDetailModal = (request: EventRequest) => {
|
||||||
|
setSelectedRequest(request);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
const closeModal = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSelectedRequest(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle sort change
|
||||||
|
const handleSortChange = (field: string) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
// Toggle direction if same field
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
// Set new field and default to descending
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply filters when filter state changes
|
||||||
|
useEffect(() => {
|
||||||
|
applyFilters();
|
||||||
|
}, [statusFilter, searchTerm, sortField, sortDirection]);
|
||||||
|
|
||||||
|
// Auto refresh on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
refreshEventRequests();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (filteredRequests.length === 0) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="bg-base-200 rounded-xl p-8 text-center shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center py-6">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="text-xl font-semibold mb-3">No Event Requests Found</h3>
|
||||||
|
<p className="text-base-content/60 mb-6 max-w-md">
|
||||||
|
{statusFilter !== 'all' || searchTerm
|
||||||
|
? 'No event requests match your current filters. Try adjusting your search criteria.'
|
||||||
|
: 'There are no event requests in the system yet.'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-sm gap-2"
|
||||||
|
onClick={refreshEventRequests}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
>
|
||||||
|
{isRefreshing ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-xs"></span>
|
||||||
|
Refreshing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
Refresh
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{(statusFilter !== 'all' || searchTerm) && (
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-sm gap-2"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter('all');
|
||||||
|
setSearchTerm('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="space-y-6"
|
||||||
|
style={{ minHeight: "500px" }}
|
||||||
|
>
|
||||||
|
{/* Filters and controls */}
|
||||||
|
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 mb-4">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 w-full lg:w-auto">
|
||||||
|
<div className="form-control w-full sm:w-auto">
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search events..."
|
||||||
|
className="input input-bordered w-full sm:w-64"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-square">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="select select-bordered w-full sm:w-auto"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="declined">Declined</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 w-full lg:w-auto justify-between sm:justify-end">
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{filteredRequests.length} {filteredRequests.length === 1 ? 'request' : 'requests'} found
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-sm gap-2"
|
||||||
|
onClick={refreshEventRequests}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
>
|
||||||
|
{isRefreshing ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-xs"></span>
|
||||||
|
Refreshing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
Refresh
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event requests table */}
|
||||||
|
<div
|
||||||
|
className="rounded-xl shadow-sm overflow-x-auto"
|
||||||
|
style={{
|
||||||
|
maxHeight: "unset",
|
||||||
|
height: "auto"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table className="table table-zebra w-full">
|
||||||
|
<thead className="bg-base-300/50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
className="cursor-pointer hover:bg-base-300"
|
||||||
|
onClick={() => handleSortChange('name')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Event Name
|
||||||
|
{sortField === 'name' && (
|
||||||
|
<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={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="cursor-pointer hover:bg-base-300 hidden md:table-cell"
|
||||||
|
onClick={() => handleSortChange('start_date_time')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Date
|
||||||
|
{sortField === 'start_date_time' && (
|
||||||
|
<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={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="cursor-pointer hover:bg-base-300"
|
||||||
|
onClick={() => handleSortChange('requested_user')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Requested By
|
||||||
|
{sortField === 'requested_user' && (
|
||||||
|
<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={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="hidden lg:table-cell">PR Materials</th>
|
||||||
|
<th className="hidden lg:table-cell">AS Funding</th>
|
||||||
|
<th
|
||||||
|
className="cursor-pointer hover:bg-base-300 hidden md:table-cell"
|
||||||
|
onClick={() => handleSortChange('created')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Submitted
|
||||||
|
{sortField === 'created' && (
|
||||||
|
<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={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="cursor-pointer hover:bg-base-300"
|
||||||
|
onClick={() => handleSortChange('status')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Status
|
||||||
|
{sortField === 'status' && (
|
||||||
|
<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={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredRequests.map((request) => (
|
||||||
|
<tr key={request.id} className="hover">
|
||||||
|
<td className="font-medium">{request.name}</td>
|
||||||
|
<td className="hidden md:table-cell">{formatDate(request.start_date_time)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{request.expand?.requested_user?.name || 'Unknown'}</span>
|
||||||
|
<span className="text-xs text-gray-400">{request.expand?.requested_user?.email}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="hidden lg:table-cell">
|
||||||
|
{request.flyers_needed ? (
|
||||||
|
<span className="badge badge-success badge-sm">Yes</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-ghost badge-sm">No</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="hidden lg:table-cell">
|
||||||
|
{request.as_funding_required ? (
|
||||||
|
<span className="badge badge-success badge-sm">Yes</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-ghost badge-sm">No</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="hidden md:table-cell">{formatDate(request.created)}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${getStatusBadge(request.status)}`}>
|
||||||
|
{request.status || 'Pending'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="dropdown dropdown-end">
|
||||||
|
<label tabIndex={0} className="btn btn-sm btn-outline">
|
||||||
|
Update
|
||||||
|
</label>
|
||||||
|
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
|
||||||
|
<li><a onClick={() => updateEventRequestStatus(request.id, 'Pending')}>Pending</a></li>
|
||||||
|
<li><a onClick={() => updateEventRequestStatus(request.id, 'Completed')}>Completed</a></li>
|
||||||
|
<li><a onClick={() => updateEventRequestStatus(request.id, 'Declined')}>Declined</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => openDetailModal(request)}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event request details modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isModalOpen && selectedRequest && (
|
||||||
|
<EventRequestDetails
|
||||||
|
request={selectedRequest}
|
||||||
|
onClose={closeModal}
|
||||||
|
onStatusChange={updateEventRequestStatus}
|
||||||
|
onFeedbackChange={addFeedback}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EventRequestManagementTable;
|
|
@ -32,10 +32,17 @@ sections:
|
||||||
reimbursementManagement:
|
reimbursementManagement:
|
||||||
title: "Reimbursement Management"
|
title: "Reimbursement Management"
|
||||||
icon: "heroicons:credit-card"
|
icon: "heroicons:credit-card"
|
||||||
role: "general"
|
role: "executive"
|
||||||
component: "Officer_ReimbursementManagement"
|
component: "Officer_ReimbursementManagement"
|
||||||
class: "text-info hover:text-info-focus"
|
class: "text-info hover:text-info-focus"
|
||||||
|
|
||||||
|
eventRequestManagement:
|
||||||
|
title: "Event Request Management"
|
||||||
|
icon: "heroicons:document-text"
|
||||||
|
role: "executive"
|
||||||
|
component: "Officer_EventRequestManagement"
|
||||||
|
class: "text-info hover:text-info-focus"
|
||||||
|
|
||||||
eventRequestForm:
|
eventRequestForm:
|
||||||
title: "Event Request Form"
|
title: "Event Request Form"
|
||||||
icon: "heroicons:document-text"
|
icon: "heroicons:document-text"
|
||||||
|
@ -86,7 +93,7 @@ categories:
|
||||||
|
|
||||||
executive:
|
executive:
|
||||||
title: "Executive Menu"
|
title: "Executive Menu"
|
||||||
sections: ["reimbursementManagement"]
|
sections: ["reimbursementManagement", "eventRequestManagement"]
|
||||||
role: "executive"
|
role: "executive"
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
|
|
|
@ -10,6 +10,7 @@ interface BaseRecord {
|
||||||
interface RequestOptions {
|
interface RequestOptions {
|
||||||
fields?: string[];
|
fields?: string[];
|
||||||
disableAutoCancellation?: boolean;
|
disableAutoCancellation?: boolean;
|
||||||
|
expand?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility function to check if a value is a UTC date string
|
// Utility function to check if a value is a UTC date string
|
||||||
|
@ -222,6 +223,7 @@ export class Get {
|
||||||
...(filter && { filter }),
|
...(filter && { filter }),
|
||||||
...(sort && { sort }),
|
...(sort && { sort }),
|
||||||
...(options?.fields && { fields: options.fields.join(",") }),
|
...(options?.fields && { fields: options.fields.join(",") }),
|
||||||
|
...(options?.expand && { expand: options.expand.join(",") }),
|
||||||
...(options?.disableAutoCancellation && { requestKey: null }),
|
...(options?.disableAutoCancellation && { requestKey: null }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue