880 lines
No EOL
43 KiB
TypeScript
880 lines
No EOL
43 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
|
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
|
import toast from 'react-hot-toast';
|
|
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
|
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
|
|
|
// Extended EventRequest interface with additional properties needed for this component
|
|
interface ExtendedEventRequest extends SchemaEventRequest {
|
|
requested_user_expand?: {
|
|
name: string;
|
|
email: string;
|
|
};
|
|
expand?: {
|
|
requested_user?: {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
[key: string]: any;
|
|
};
|
|
[key: string]: any;
|
|
};
|
|
invoice_data?: any;
|
|
invoice_files?: string[]; // Array of invoice file IDs
|
|
status: "submitted" | "pending" | "completed" | "declined";
|
|
declined_reason?: string; // Reason for declining the event request
|
|
flyers_completed?: boolean; // Track if flyers have been completed by PR team
|
|
}
|
|
|
|
interface EventRequestManagementTableProps {
|
|
eventRequests: ExtendedEventRequest[];
|
|
onRequestSelect: (request: ExtendedEventRequest) => void;
|
|
onStatusChange: (id: string, status: "submitted" | "pending" | "completed" | "declined") => Promise<void>;
|
|
isLoadingUserData?: boolean;
|
|
}
|
|
|
|
const EventRequestManagementTable = ({
|
|
eventRequests: initialEventRequests,
|
|
onRequestSelect,
|
|
onStatusChange,
|
|
isLoadingUserData = false
|
|
}: EventRequestManagementTableProps) => {
|
|
const [eventRequests, setEventRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
|
|
const [filteredRequests, setFilteredRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
|
|
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');
|
|
const dataSync = DataSyncService.getInstance();
|
|
// Add state for update modal
|
|
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState<boolean>(false);
|
|
const [requestToUpdate, setRequestToUpdate] = useState<ExtendedEventRequest | null>(null);
|
|
// Add state for decline reason modal
|
|
const [isDeclineModalOpen, setIsDeclineModalOpen] = useState<boolean>(false);
|
|
const [declineReason, setDeclineReason] = useState<string>('');
|
|
const [requestToDecline, setRequestToDecline] = useState<ExtendedEventRequest | null>(null);
|
|
|
|
// Refresh event requests
|
|
const refreshEventRequests = async () => {
|
|
setIsRefreshing(true);
|
|
try {
|
|
const auth = Authentication.getInstance();
|
|
|
|
// Don't check authentication here - try to fetch anyway
|
|
// The token might be valid for the API even if isAuthenticated() returns false
|
|
|
|
// console.log("Fetching event requests...");
|
|
|
|
// Use DataSyncService to get data from IndexedDB with forced sync
|
|
const updatedRequests = await dataSync.getData<ExtendedEventRequest>(
|
|
Collections.EVENT_REQUESTS,
|
|
true, // Force sync
|
|
'', // No filter
|
|
'-created',
|
|
'requested_user' // This is correct but we need to ensure it's working in the DataSyncService
|
|
);
|
|
|
|
// If we still have "Unknown" users, try to fetch them directly
|
|
const requestsWithUsers = await Promise.all(
|
|
updatedRequests.map(async (request) => {
|
|
// If user data is missing, try to fetch it directly
|
|
if (!request.expand?.requested_user && request.requested_user) {
|
|
try {
|
|
const userData = await dataSync.getItem(
|
|
Collections.USERS,
|
|
request.requested_user,
|
|
true // Force sync the user data
|
|
);
|
|
|
|
if (userData) {
|
|
// TypeScript cast to access the properties
|
|
const typedUserData = userData as unknown as {
|
|
id: string;
|
|
name?: string;
|
|
email?: string;
|
|
};
|
|
|
|
// Update the expand object with the user data
|
|
return {
|
|
...request,
|
|
expand: {
|
|
...(request.expand || {}),
|
|
requested_user: {
|
|
id: typedUserData.id,
|
|
name: typedUserData.name || 'Unknown',
|
|
email: typedUserData.email || 'Unknown'
|
|
}
|
|
}
|
|
} as ExtendedEventRequest;
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error fetching user data for request ${request.id}:`, error);
|
|
}
|
|
}
|
|
return request;
|
|
})
|
|
) as ExtendedEventRequest[];
|
|
|
|
// console.log(`Fetched ${updatedRequests.length} event requests`);
|
|
|
|
setEventRequests(requestsWithUsers);
|
|
applyFilters(requestsWithUsers);
|
|
} catch (error) {
|
|
// console.error('Error refreshing event requests:', error);
|
|
toast.error('Failed to refresh event requests');
|
|
} 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 ExtendedEventRequest];
|
|
let bValue: any = b[sortField as keyof ExtendedEventRequest];
|
|
|
|
// 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: "submitted" | "pending" | "completed" | "declined", declineReason?: string): Promise<void> => {
|
|
try {
|
|
// Find the event request to get its current status and name
|
|
const eventRequest = eventRequests.find(req => req.id === id);
|
|
const eventName = eventRequest?.name || 'Event';
|
|
const previousStatus = eventRequest?.status;
|
|
|
|
// If declining, update with decline reason
|
|
if (status === 'declined' && declineReason) {
|
|
const { Update } = await import('../../../scripts/pocketbase/Update');
|
|
const update = Update.getInstance();
|
|
await update.updateFields("event_request", id, {
|
|
status: status,
|
|
declined_reason: declineReason
|
|
});
|
|
} else {
|
|
await onStatusChange(id, status);
|
|
}
|
|
|
|
// Update local state
|
|
setEventRequests(prev =>
|
|
prev.map(request =>
|
|
request.id === id ? {
|
|
...request,
|
|
status,
|
|
...(status === 'declined' && declineReason ? { declined_reason: declineReason } : {})
|
|
} : request
|
|
)
|
|
);
|
|
|
|
setFilteredRequests(prev =>
|
|
prev.map(request =>
|
|
request.id === id ? {
|
|
...request,
|
|
status,
|
|
...(status === 'declined' && declineReason ? { declined_reason: declineReason } : {})
|
|
} : request
|
|
)
|
|
);
|
|
|
|
toast.success(`"${eventName}" status updated to ${status}`);
|
|
|
|
// Send email notification for status change
|
|
try {
|
|
const { EmailClient } = await import('../../../scripts/email/EmailClient');
|
|
const auth = Authentication.getInstance();
|
|
const changedByUserId = auth.getUserId();
|
|
|
|
if (previousStatus && previousStatus !== status) {
|
|
await EmailClient.notifyEventRequestStatusChange(
|
|
id,
|
|
previousStatus,
|
|
status,
|
|
changedByUserId || undefined,
|
|
status === 'declined' ? declineReason : undefined
|
|
);
|
|
console.log('Event request status change notification email sent successfully');
|
|
}
|
|
|
|
// Send design team notifications for PR-related actions
|
|
if (eventRequest?.flyers_needed) {
|
|
if (status === 'declined') {
|
|
await EmailClient.notifyDesignTeam(id, 'declined');
|
|
console.log('Design team notified of declined PR request');
|
|
}
|
|
}
|
|
} catch (emailError) {
|
|
console.error('Failed to send event request status change notification email:', emailError);
|
|
// Don't show error to user - email failure shouldn't disrupt the main operation
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error updating status:', error);
|
|
toast.error('Failed to update status');
|
|
}
|
|
};
|
|
|
|
// Update PR status (flyers_completed)
|
|
const updatePRStatus = async (id: string, completed: boolean): Promise<void> => {
|
|
try {
|
|
const { Update } = await import('../../../scripts/pocketbase/Update');
|
|
const update = Update.getInstance();
|
|
|
|
await update.updateField("event_request", id, "flyers_completed", completed);
|
|
|
|
// Find the event request to get its details
|
|
const eventRequest = eventRequests.find(req => req.id === id);
|
|
const eventName = eventRequest?.name || 'Event';
|
|
|
|
// Update local state
|
|
setEventRequests(prev =>
|
|
prev.map(request =>
|
|
request.id === id ? { ...request, flyers_completed: completed } : request
|
|
)
|
|
);
|
|
|
|
setFilteredRequests(prev =>
|
|
prev.map(request =>
|
|
request.id === id ? { ...request, flyers_completed: completed } : request
|
|
)
|
|
);
|
|
|
|
toast.success(`"${eventName}" PR status updated to ${completed ? 'completed' : 'pending'}`);
|
|
|
|
// Send email notification if PR is completed
|
|
if (completed) {
|
|
try {
|
|
const { EmailClient } = await import('../../../scripts/email/EmailClient');
|
|
await EmailClient.notifyPRCompleted(id);
|
|
console.log('PR completion notification email sent successfully');
|
|
} catch (emailError) {
|
|
console.error('Failed to send PR completion notification email:', emailError);
|
|
// Don't show error to user - email failure shouldn't disrupt the main operation
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error updating PR status:', error);
|
|
toast.error('Failed to update PR status');
|
|
}
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
};
|
|
|
|
// Format date and time range for display
|
|
const formatDateTimeRange = (startDateString: string, endDateString: string) => {
|
|
if (!startDateString) return 'Not specified';
|
|
|
|
try {
|
|
const startDate = new Date(startDateString);
|
|
const endDate = endDateString ? new Date(endDateString) : null;
|
|
|
|
const startFormatted = startDate.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
|
|
if (endDate && endDate.getTime() !== startDate.getTime()) {
|
|
// Check if it's the same day
|
|
const isSameDay = startDate.toDateString() === endDate.toDateString();
|
|
|
|
if (isSameDay) {
|
|
// Same day, just show end time
|
|
const endTime = endDate.toLocaleTimeString('en-US', {
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
return `${startFormatted} - ${endTime}`;
|
|
} else {
|
|
// Different day, show full end date
|
|
const endFormatted = endDate.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
return `${startFormatted} - ${endFormatted}`;
|
|
}
|
|
}
|
|
|
|
return startFormatted;
|
|
} catch (e) {
|
|
return startDateString;
|
|
}
|
|
};
|
|
|
|
// Get status badge class based on status
|
|
const getStatusBadge = (status?: "submitted" | "pending" | "completed" | "declined") => {
|
|
if (!status) return 'badge-warning';
|
|
|
|
switch (status) {
|
|
case 'completed':
|
|
return 'badge-success';
|
|
case 'declined':
|
|
return 'badge-error';
|
|
case 'pending':
|
|
return 'badge-warning';
|
|
case 'submitted':
|
|
return 'badge-info';
|
|
default:
|
|
return 'badge-warning';
|
|
}
|
|
};
|
|
|
|
// Helper function to truncate text
|
|
const truncateText = (text: string, maxLength: number) => {
|
|
if (!text) return '';
|
|
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
|
};
|
|
|
|
// Helper to get user display info - always show email address
|
|
const getUserDisplayInfo = (request: ExtendedEventRequest) => {
|
|
// If we're still loading user data, show loading indicator
|
|
if (isLoadingUserData) {
|
|
return {
|
|
name: request.expand?.requested_user?.name || 'Loading...',
|
|
email: request.expand?.requested_user?.email || 'Loading...'
|
|
};
|
|
}
|
|
|
|
// First try to get from the expand object
|
|
if (request.expand?.requested_user) {
|
|
const user = request.expand.requested_user;
|
|
const name = user.name || 'Unknown';
|
|
// Always show email regardless of emailVisibility
|
|
const email = user.email || 'Unknown';
|
|
return { name, email };
|
|
}
|
|
|
|
// Then try the requested_user_expand
|
|
if (request.requested_user_expand) {
|
|
const name = request.requested_user_expand.name || 'Unknown';
|
|
const email = request.requested_user_expand.email || 'Unknown';
|
|
return { name, email };
|
|
}
|
|
|
|
// Last fallback
|
|
return { name: 'Unknown', email: 'Unknown' };
|
|
};
|
|
|
|
// Update openDetailModal to call the prop function
|
|
const openDetailModal = (request: ExtendedEventRequest) => {
|
|
onRequestSelect(request);
|
|
};
|
|
|
|
// 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');
|
|
}
|
|
};
|
|
|
|
// Handle decline action with reason prompt
|
|
const handleDeclineAction = (request: ExtendedEventRequest) => {
|
|
setRequestToDecline(request);
|
|
setDeclineReason('');
|
|
setIsDeclineModalOpen(true);
|
|
};
|
|
|
|
// Confirm decline with reason
|
|
const confirmDecline = async () => {
|
|
if (!requestToDecline || !declineReason.trim()) {
|
|
toast.error('Please provide a reason for declining');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await updateEventRequestStatus(requestToDecline.id, 'declined', declineReason);
|
|
setIsDeclineModalOpen(false);
|
|
setRequestToDecline(null);
|
|
setDeclineReason('');
|
|
} catch (error) {
|
|
console.error('Error declining request:', error);
|
|
toast.error('Failed to decline request');
|
|
}
|
|
};
|
|
|
|
// Cancel decline action
|
|
const cancelDecline = () => {
|
|
setIsDeclineModalOpen(false);
|
|
setRequestToDecline(null);
|
|
setDeclineReason('');
|
|
};
|
|
|
|
// Apply filters when filter state changes
|
|
useEffect(() => {
|
|
applyFilters();
|
|
}, [statusFilter, searchTerm, sortField, sortDirection]);
|
|
|
|
// Check authentication and refresh token if needed
|
|
useEffect(() => {
|
|
const checkAuth = async () => {
|
|
const auth = Authentication.getInstance();
|
|
|
|
// Check if we're authenticated
|
|
if (!auth.isAuthenticated()) {
|
|
// console.log("Authentication check failed - attempting to continue anyway");
|
|
|
|
// Don't show error or redirect immediately - try to refresh first
|
|
try {
|
|
// Try to refresh event requests anyway - the token might be valid
|
|
await refreshEventRequests();
|
|
} catch (err) {
|
|
// console.error("Failed to refresh after auth check:", err);
|
|
toast.error("Authentication error. Please log in again.");
|
|
|
|
// Only redirect if refresh fails
|
|
setTimeout(() => {
|
|
window.location.href = "/login";
|
|
}, 2000);
|
|
}
|
|
} else {
|
|
// console.log("Authentication check passed");
|
|
}
|
|
};
|
|
|
|
checkAuth();
|
|
}, []);
|
|
|
|
// Auto refresh on component mount
|
|
useEffect(() => {
|
|
refreshEventRequests();
|
|
}, []);
|
|
|
|
// Listen for tab visibility changes and refresh data when tab becomes visible
|
|
useEffect(() => {
|
|
const handleTabVisible = () => {
|
|
// console.log("Tab became visible, refreshing event requests...");
|
|
refreshEventRequests();
|
|
};
|
|
|
|
// Add event listener for custom dashboardTabVisible event
|
|
document.addEventListener("dashboardTabVisible", handleTabVisible);
|
|
|
|
// Clean up event listener on component unmount
|
|
return () => {
|
|
document.removeEventListener("dashboardTabVisible", handleTabVisible);
|
|
};
|
|
}, []);
|
|
|
|
if (filteredRequests.length === 0) {
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="bg-gradient-to-b from-base-200 to-base-300 rounded-xl p-8 text-center shadow-sm border border-base-300/30"
|
|
>
|
|
<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 text-white">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-primary 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-ghost 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 p-4 bg-base-300/50 rounded-lg border border-base-100/10 mb-6">
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 w-full lg:w-auto">
|
|
<div className="relative flex items-center w-full sm:w-auto">
|
|
<input
|
|
type="text"
|
|
placeholder="Search events..."
|
|
className="input bg-[#1e2029] border-[#2a2d3a] focus:border-primary w-full sm:w-64 pr-10 rounded-lg"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
<button className="absolute right-0 top-0 btn btn-square bg-primary text-white hover:bg-primary/90 border-none rounded-l-none h-full">
|
|
<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 className="relative w-full sm:w-auto">
|
|
<select
|
|
className="select bg-[#1e2029] border-[#2a2d3a] focus:border-primary w-full appearance-none pr-10 rounded-lg"
|
|
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>
|
|
<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-primary 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 bg-base-100/10 border border-base-100/20"
|
|
style={{
|
|
maxHeight: "unset",
|
|
height: "auto"
|
|
}}
|
|
>
|
|
<table className="table table-zebra w-full min-w-[600px]">
|
|
<thead className="bg-base-300/50 sticky top-0 z-10">
|
|
<tr>
|
|
<th
|
|
className="cursor-pointer hover:bg-base-300 transition-colors"
|
|
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 text-primary" 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 transition-colors hidden md:table-cell"
|
|
onClick={() => handleSortChange('start_date_time')}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
Date & Time
|
|
{sortField === 'start_date_time' && (
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary" 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 transition-colors"
|
|
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 text-primary" 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="cursor-pointer hover:bg-base-300 transition-colors hidden lg:table-cell"
|
|
onClick={() => handleSortChange('flyers_completed')}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
PR Status
|
|
{sortField === 'flyers_completed' && (
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary" 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">AS Funding</th>
|
|
<th
|
|
className="cursor-pointer hover:bg-base-300 transition-colors 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 text-primary" 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 transition-colors"
|
|
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 text-primary" 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="w-20 min-w-[5rem]">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredRequests.map((request) => (
|
|
<tr key={request.id} className="hover transition-colors">
|
|
<td className="font-medium">
|
|
<div className="truncate max-w-[180px] md:max-w-[250px]" title={request.name}>
|
|
{truncateText(request.name, 30)}
|
|
</div>
|
|
</td>
|
|
<td className="hidden md:table-cell">
|
|
<div className="text-sm">
|
|
{formatDateTimeRange(request.start_date_time, request.end_date_time)}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
{(() => {
|
|
const { name, email } = getUserDisplayInfo(request);
|
|
return (
|
|
<div className="flex flex-col">
|
|
<span>{name}</span>
|
|
<span className="text-xs text-gray-400">{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.flyers_needed ? (
|
|
<input
|
|
type="checkbox"
|
|
checked={request.flyers_completed || false}
|
|
onChange={(e) => {
|
|
e.stopPropagation();
|
|
updatePRStatus(request.id, e.target.checked);
|
|
}}
|
|
className="checkbox checkbox-primary"
|
|
title="Mark PR materials as completed"
|
|
/>
|
|
) : (
|
|
<input
|
|
type="checkbox"
|
|
checked={false}
|
|
disabled={true}
|
|
className="checkbox checkbox-disabled opacity-30"
|
|
title="PR materials not needed for this event"
|
|
/>
|
|
)}
|
|
</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?.charAt(0).toUpperCase() + request.status?.slice(1) || 'Pending'}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<div className="flex items-center justify-center">
|
|
<button
|
|
className="btn btn-sm btn-primary btn-outline gap-1 min-h-[2rem] max-w-[5rem] flex-shrink-0"
|
|
onClick={() => openDetailModal(request)}
|
|
title="View Event Details"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 flex-shrink-0" 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>
|
|
<span className="hidden sm:inline">View</span>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Decline Reason Modal */}
|
|
{isDeclineModalOpen && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.95 }}
|
|
className="bg-base-200 rounded-lg p-6 w-full max-w-md shadow-xl"
|
|
>
|
|
<h3 className="text-lg font-semibold text-white mb-4">
|
|
Decline Event Request
|
|
</h3>
|
|
<p className="text-gray-300 mb-4">
|
|
Please provide a reason for declining "{requestToDecline?.name}". This will be sent to the submitter.
|
|
</p>
|
|
<textarea
|
|
className="textarea textarea-bordered w-full h-32 bg-base-300 text-white border-base-300 focus:border-primary"
|
|
placeholder="Enter decline reason (required)..."
|
|
value={declineReason}
|
|
onChange={(e) => setDeclineReason(e.target.value)}
|
|
maxLength={500}
|
|
/>
|
|
<div className="text-xs text-gray-400 mb-4">
|
|
{declineReason.length}/500 characters
|
|
</div>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
className="btn btn-ghost"
|
|
onClick={cancelDecline}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="btn btn-error"
|
|
onClick={confirmDecline}
|
|
disabled={!declineReason.trim()}
|
|
>
|
|
Decline Request
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default EventRequestManagementTable;
|