ieeeucsd-org/src/components/dashboard/Officer_EventManagement/EventEditor.tsx
2025-03-05 03:46:42 -08:00

934 lines
38 KiB
TypeScript

import React, { useState, useEffect, useCallback, useMemo, memo } from "react";
import { Icon } from "@iconify/react";
import { Get } from "../../../scripts/pocketbase/Get";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { Update } from "../../../scripts/pocketbase/Update";
import { FileManager } from "../../../scripts/pocketbase/FileManager";
import { SendLog } from "../../../scripts/pocketbase/SendLog";
import FilePreview from "../universal/FilePreview";
import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase";
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import toast from "react-hot-toast";
// Note: Date conversion is now handled automatically by the Get and Update classes.
// When fetching events, UTC dates are converted to local time by the Get class.
// When saving events, local dates are converted back to UTC by the Update class.
// For datetime-local inputs, we format dates without seconds (YYYY-MM-DDThh:mm).
// Extended Event interface with optional created and updated fields
interface Event extends Omit<SchemaEvent, 'created' | 'updated'> {
created?: string;
updated?: string;
}
// Extend Window interface
declare global {
interface Window {
showLoading?: () => void;
hideLoading?: () => void;
lastCacheUpdate?: number;
fetchEvents?: () => void;
}
}
interface EventEditorProps {
onEventSaved?: () => void;
}
// Memoize the FilePreview component
const MemoizedFilePreview = memo(FilePreview);
// Define EventForm props interface
interface EventFormProps {
event: Event | null;
setEvent: (field: keyof Event, value: any) => void;
selectedFiles: Map<string, File>;
setSelectedFiles: React.Dispatch<React.SetStateAction<Map<string, File>>>;
filesToDelete: Set<string>;
setFilesToDelete: React.Dispatch<React.SetStateAction<Set<string>>>;
handlePreviewFile: (url: string, filename: string) => void;
isSubmitting: boolean;
fileManager: FileManager;
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
onCancel: () => void;
}
// Create a memoized form component
const EventForm = memo(({
event,
setEvent,
selectedFiles,
setSelectedFiles,
filesToDelete,
setFilesToDelete,
handlePreviewFile,
isSubmitting,
fileManager,
onSubmit,
onCancel
}: EventFormProps): React.ReactElement => {
const handleChange = (field: keyof Event, value: any) => {
setEvent(field, value);
};
return (
<div id="editFormSection">
<h3 className="font-bold text-lg mb-4" id="editModalTitle">
{event?.id ? 'Edit Event' : 'Add New Event'}
</h3>
<form
id="editEventForm"
className="space-y-4"
onSubmit={onSubmit}
>
<input type="hidden" id="editEventId" name="editEventId" value={event?.id || ''} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Event Name */}
<div className="form-control">
<label className="label">
<span className="label-text">Event Name</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
name="editEventName"
className="input input-bordered"
value={event?.event_name || ""}
onChange={(e) => handleChange('event_name', e.target.value)}
required
/>
</div>
{/* Event Code */}
<div className="form-control">
<label className="label">
<span className="label-text">Event Code</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
name="editEventCode"
className="input input-bordered"
value={event?.event_code || ""}
onChange={(e) => handleChange('event_code', e.target.value)}
required
/>
</div>
{/* Location */}
<div className="form-control">
<label className="label">
<span className="label-text">Location</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
name="editEventLocation"
className="input input-bordered"
value={event?.location || ""}
onChange={(e) => handleChange('location', e.target.value)}
required
/>
</div>
{/* Points to Reward */}
<div className="form-control">
<label className="label">
<span className="label-text">Points to Reward</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="number"
name="editEventPoints"
className="input input-bordered"
value={event?.points_to_reward || 0}
onChange={(e) => handleChange('points_to_reward', Number(e.target.value))}
min="0"
required
/>
</div>
{/* Start Date */}
<div className="form-control">
<label className="label">
<span className="label-text">Start Date</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="datetime-local"
name="editEventStartDate"
className="input input-bordered"
value={event?.start_date || ""}
onChange={(e) => handleChange('start_date', e.target.value)}
required
/>
</div>
{/* End Date */}
<div className="form-control">
<label className="label">
<span className="label-text">End Date</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="datetime-local"
name="editEventEndDate"
className="input input-bordered"
value={event?.end_date || ""}
onChange={(e) => handleChange('end_date', e.target.value)}
required
/>
</div>
</div>
{/* Description */}
<div className="form-control">
<label className="label">
<span className="label-text">Description</span>
<span className="label-text-alt text-error">*</span>
</label>
<textarea
name="editEventDescription"
className="textarea textarea-bordered"
value={event?.event_description || ""}
onChange={(e) => handleChange('event_description', e.target.value)}
rows={3}
required
></textarea>
</div>
{/* Files */}
<div className="form-control">
<label className="label">
<span className="label-text">Upload Files</span>
</label>
<input
type="file"
name="editEventFiles"
onChange={(e) => {
if (e.target.files) {
const newFiles = new Map(selectedFiles);
const rejectedFiles: { name: string, reason: string }[] = [];
const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB
Array.from(e.target.files).forEach(file => {
// Validate file size
if (file.size > MAX_FILE_SIZE) {
const fileSizeMB = (file.size / (1024 * 1024)).toFixed(2);
rejectedFiles.push({
name: file.name,
reason: `exceeds size limit (${fileSizeMB}MB > 200MB)`
});
return;
}
// Validate file type
const validation = fileManager.validateFileType(file);
if (!validation.valid) {
rejectedFiles.push({
name: file.name,
reason: validation.reason || 'unsupported file type'
});
return;
}
// Only add valid files
newFiles.set(file.name, file);
});
// Show error for rejected files
if (rejectedFiles.length > 0) {
const errorMessage = `The following files were not added:\n${rejectedFiles.map(f => `${f.name}: ${f.reason}`).join('\n')}`;
toast.error(errorMessage);
}
setSelectedFiles(newFiles);
}
}}
className="file-input file-input-bordered"
multiple
/>
<div className="mt-4 space-y-2">
{/* New Files */}
{Array.from(selectedFiles.entries()).map(([name, file]) => (
<div key={name} className="flex items-center justify-between p-2 bg-base-200 rounded-lg">
<span className="truncate">{name}</span>
<div className="flex gap-2">
<div className="badge badge-primary">New</div>
<button
type="button"
className="btn btn-ghost btn-xs text-error"
onClick={() => {
const updatedFiles = new Map(selectedFiles);
updatedFiles.delete(name);
setSelectedFiles(updatedFiles);
}}
>
<Icon icon="heroicons:x-circle" className="h-4 w-4" />
</button>
</div>
</div>
))}
{/* Current Files */}
{event?.files && event.files.length > 0 && (
<>
<div className="divider">Current Files</div>
{event.files.map((filename) => (
<div key={filename} className={`flex items-center justify-between p-2 bg-base-200 rounded-lg${filesToDelete.has(filename) ? " opacity-50" : ""}`}>
<span className="truncate">{filename}</span>
<div className="flex gap-2">
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => {
if (event?.id) {
handlePreviewFile(
fileManager.getFileUrl("events", event.id, filename),
filename
);
}
}}
>
<Icon icon="heroicons:eye" className="h-4 w-4" />
</button>
<div className="text-error">
{filesToDelete.has(filename) ? (
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => {
const newFilesToDelete = new Set(filesToDelete);
newFilesToDelete.delete(filename);
setFilesToDelete(newFilesToDelete);
}}
>
<Icon icon="heroicons:trash" className="h-4 w-4" />
</button>
) : (
<button
type="button"
className="btn btn-ghost btn-xs text-error"
onClick={() => {
const newFilesToDelete = new Set(filesToDelete);
newFilesToDelete.add(filename);
setFilesToDelete(newFilesToDelete);
}}
>
<Icon icon="heroicons:trash" className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
))}
</>
)}
</div>
</div>
{/* Published */}
<div className="form-control">
<label className="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
name="editEventPublished"
className="toggle"
checked={event?.published || false}
onChange={(e) => handleChange('published', e.target.checked)}
/>
<span className="label-text">Publish Event</span>
</label>
<label className="label">
<span className="label-text-alt text-info">
This has to be clicked if you want to make this event available
to the public
</span>
</label>
</div>
{/* Has Food */}
<div className="form-control">
<label className="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
name="editEventHasFood"
className="toggle"
checked={event?.has_food || false}
onChange={(e) => handleChange('has_food', e.target.checked)}
/>
<span className="label-text">Has Food</span>
</label>
<label className="label">
<span className="label-text-alt text-info">
Check this if food will be provided at the event
</span>
</label>
</div>
{/* Action Buttons */}
<div className="modal-action mt-6">
<button
type="submit"
className={`btn btn-primary ${isSubmitting ? 'loading' : ''}`}
disabled={isSubmitting}
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
<button
type="button"
className="btn"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</button>
</div>
</form>
</div>
);
});
// Add new interfaces for change tracking
interface EventChanges {
event_name?: string;
event_description?: string;
event_code?: string;
location?: string;
points_to_reward?: number;
start_date?: string;
end_date?: string;
published?: boolean;
has_food?: boolean;
}
interface FileChanges {
added: Map<string, File>;
deleted: Set<string>;
unchanged: string[];
}
// Add queue management for large datasets
class UploadQueue {
private queue: Array<() => Promise<void>> = [];
private processing = false;
private readonly BATCH_SIZE = 5;
async add(task: () => Promise<void>) {
this.queue.push(task);
if (!this.processing) {
await this.process();
}
}
private async process() {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
try {
while (this.queue.length > 0) {
const batch = this.queue.splice(0, this.BATCH_SIZE);
await Promise.all(batch.map(task => task()));
}
} finally {
this.processing = false;
}
}
}
// Add change tracking utility
class ChangeTracker {
private initialState: Event | null = null;
private currentState: Event | null = null;
private fileChanges: FileChanges = {
added: new Map(),
deleted: new Set(),
unchanged: []
};
initialize(event: Event | null) {
this.initialState = event ? { ...event } : null;
this.currentState = event ? { ...event } : null;
this.fileChanges = {
added: new Map(),
deleted: new Set(),
unchanged: event?.files || []
};
}
trackChange(field: keyof Event, value: any) {
if (!this.currentState) {
this.currentState = {} as Event;
}
(this.currentState as any)[field] = value;
}
trackFileChange(added: Map<string, File>, deleted: Set<string>) {
this.fileChanges.added = added;
this.fileChanges.deleted = deleted;
if (this.initialState?.files) {
this.fileChanges.unchanged = this.initialState.files.filter(
file => !deleted.has(file)
);
}
}
getChanges(): EventChanges {
if (!this.initialState || !this.currentState) return {};
const changes: EventChanges = {};
const fields: (keyof EventChanges)[] = [
'event_name',
'event_description',
'event_code',
'location',
'points_to_reward',
'start_date',
'end_date',
'published',
'has_food'
];
for (const field of fields) {
if (this.initialState[field] !== this.currentState[field]) {
(changes[field] as any) = this.currentState[field];
}
}
return changes;
}
getFileChanges(): FileChanges {
return this.fileChanges;
}
hasChanges(): boolean {
return Object.keys(this.getChanges()).length > 0 ||
this.fileChanges.added.size > 0 ||
this.fileChanges.deleted.size > 0;
}
}
// Add new interfaces for loading states
interface LoadingState {
isLoading: boolean;
error: string | null;
timeoutId: NodeJS.Timeout | null;
}
// Add loading spinner component
const LoadingSpinner = memo(() => (
<div className="flex flex-col items-center justify-center p-8 space-y-4">
<div className="loading loading-spinner loading-lg text-primary"></div>
<p className="text-base-content/70">Loading event data...</p>
</div>
));
// Add error display component
const ErrorDisplay = memo(({ error, onRetry }: { error: string; onRetry: () => void }) => (
<div className="flex flex-col items-center justify-center p-8 space-y-4">
<div className="text-error">
<Icon icon="heroicons:x-circle" className="h-12 w-12" />
</div>
<p className="text-error font-medium">{error}</p>
<button className="btn btn-error btn-sm" onClick={onRetry}>
Try Again
</button>
</div>
));
// Modify EventEditor component
export default function EventEditor({ onEventSaved }: EventEditorProps) {
// State for form data and UI
const [event, setEvent] = useState<Event>({
id: "",
created: "",
updated: "",
event_name: "",
event_description: "",
event_code: "",
location: "",
files: [],
points_to_reward: 0,
start_date: "",
end_date: "",
published: false,
has_food: false
});
const [previewUrl, setPreviewUrl] = useState("");
const [previewFilename, setPreviewFilename] = useState("");
const [showPreview, setShowPreview] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<Map<string, File>>(new Map());
const [filesToDelete, setFilesToDelete] = useState<Set<string>>(new Set());
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// Memoize service instances
const services = useMemo(() => ({
get: Get.getInstance(),
auth: Authentication.getInstance(),
update: Update.getInstance(),
fileManager: FileManager.getInstance(),
sendLog: SendLog.getInstance()
}), []);
// Handle field changes
const handleFieldChange = useCallback((field: keyof Event, value: any) => {
setEvent(prev => {
const newEvent = { ...prev, [field]: value };
// Only set hasUnsavedChanges if the value actually changed
if (prev[field] !== value) {
setHasUnsavedChanges(true);
}
return newEvent;
});
}, []);
// Initialize event data
const initializeEventData = useCallback(async (eventId: string) => {
try {
if (eventId) {
// Clear cache to ensure fresh data
const dataSync = DataSyncService.getInstance();
await dataSync.clearCache();
// Fetch fresh event data
const eventData = await services.get.getOne<Event>(Collections.EVENTS, eventId);
if (!eventData) {
throw new Error("Event not found");
}
// Ensure dates are properly formatted for datetime-local input
if (eventData.start_date) {
// Convert to Date object first to ensure proper formatting
const startDate = new Date(eventData.start_date);
eventData.start_date = Get.formatLocalDate(startDate, false);
}
if (eventData.end_date) {
// Convert to Date object first to ensure proper formatting
const endDate = new Date(eventData.end_date);
eventData.end_date = Get.formatLocalDate(endDate, false);
}
// Ensure all fields are properly set
setEvent({
id: eventData.id || '',
created: eventData.created || '',
updated: eventData.updated || '',
event_name: eventData.event_name || '',
event_description: eventData.event_description || '',
event_code: eventData.event_code || '',
location: eventData.location || '',
files: eventData.files || [],
points_to_reward: eventData.points_to_reward || 0,
start_date: eventData.start_date || '',
end_date: eventData.end_date || '',
published: eventData.published || false,
has_food: eventData.has_food || false
});
console.log("Event data loaded successfully:", eventData);
} else {
setEvent({
id: '',
created: '',
updated: '',
event_name: '',
event_description: '',
event_code: '',
location: '',
files: [],
points_to_reward: 0,
start_date: '',
end_date: '',
published: false,
has_food: false
});
}
setSelectedFiles(new Map());
setFilesToDelete(new Set());
setShowPreview(false);
setHasUnsavedChanges(false);
} catch (error) {
console.error("Failed to initialize event data:", error);
toast.error("Failed to load event data. Please try again.");
}
}, [services.get]);
// Expose initializeEventData to window
useEffect(() => {
(window as any).openEditModal = async (event?: Event) => {
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
if (!modal) return;
try {
if (event?.id) {
// Always fetch fresh data from PocketBase for the event
await initializeEventData(event.id);
} else {
// Reset form for new event
await initializeEventData('');
}
modal.showModal();
} catch (error) {
console.error("Failed to open edit modal:", error);
toast.error("Failed to open edit modal. Please try again.");
}
};
return () => {
delete (window as any).openEditModal;
};
}, [initializeEventData]);
// Handler functions
const handlePreviewFile = useCallback((url: string, filename: string) => {
setPreviewUrl(url);
setPreviewFilename(filename);
setShowPreview(true);
}, []);
const handleModalClose = useCallback(() => {
if (hasUnsavedChanges) {
const confirmed = window.confirm('You have unsaved changes. Are you sure you want to close?');
if (!confirmed) return;
}
setEvent({
id: "",
created: "",
updated: "",
event_name: "",
event_description: "",
event_code: "",
location: "",
files: [],
points_to_reward: 0,
start_date: "",
end_date: "",
published: false,
has_food: false
});
setSelectedFiles(new Map());
setFilesToDelete(new Set());
setShowPreview(false);
setPreviewUrl("");
setPreviewFilename("");
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
if (modal) modal.close();
}, [hasUnsavedChanges]);
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isSubmitting) return;
try {
setIsSubmitting(true);
window.showLoading?.();
const submitButton = document.getElementById("submitEventButton") as HTMLButtonElement;
const cancelButton = document.getElementById("cancelEventButton") as HTMLButtonElement;
if (submitButton) {
submitButton.disabled = true;
submitButton.classList.add("btn-disabled");
}
if (cancelButton) cancelButton.disabled = true;
// Get form data
const formData = new FormData(e.currentTarget);
// Create updated event object
const updatedEvent: Omit<Event, 'created' | 'updated'> = {
id: event.id,
event_name: formData.get("editEventName") as string,
event_description: formData.get("editEventDescription") as string,
event_code: formData.get("editEventCode") as string,
location: formData.get("editEventLocation") as string,
files: event.files || [],
points_to_reward: parseInt(formData.get("editEventPoints") as string) || 0,
start_date: formData.get("editEventStartDate") as string,
end_date: formData.get("editEventEndDate") as string,
published: formData.get("editEventPublished") === "on",
has_food: formData.get("editEventHasFood") === "on"
};
// Log the update attempt
await services.sendLog.send(
"update",
"event",
`${event.id ? "Updating" : "Creating"} event: ${updatedEvent.event_name} (${event.id || "new"})`
);
if (event.id) {
// We're updating an existing event
// First, update the event data without touching files
const { files, ...cleanPayload } = updatedEvent;
await services.update.updateFields<Event>(
Collections.EVENTS,
event.id,
cleanPayload
);
// Handle file operations
if (filesToDelete.size > 0 || selectedFiles.size > 0) {
// Get the current event with its files
const currentEvent = await services.get.getOne<Event>(Collections.EVENTS, event.id);
let currentFiles = currentEvent?.files || [];
// 1. Remove files marked for deletion
if (filesToDelete.size > 0) {
console.log(`Removing ${filesToDelete.size} files from event ${event.id}`);
currentFiles = currentFiles.filter(file => !filesToDelete.has(file));
// Update the files field first to remove deleted files
await services.update.updateFields<Event>(
Collections.EVENTS,
event.id,
{ files: currentFiles }
);
}
// 2. Add new files one by one to preserve existing ones
if (selectedFiles.size > 0) {
console.log(`Adding ${selectedFiles.size} new files to event ${event.id}`);
// Convert Map to array of File objects
const newFiles = Array.from(selectedFiles.values());
// Use FileManager to upload each file individually
for (const file of newFiles) {
// Use the FileManager to upload this file
await services.fileManager.uploadFile(
Collections.EVENTS,
event.id,
'files',
file,
true // Set append mode to true to preserve existing files
);
}
}
}
// Get the final updated event with all changes
const savedEvent = await services.get.getOne<Event>(Collections.EVENTS, event.id);
// Clear cache to ensure fresh data
const dataSync = DataSyncService.getInstance();
await dataSync.clearCache();
// Update the window object with the latest event data
const eventDataId = `event_${event.id}`;
if ((window as any)[eventDataId]) {
(window as any)[eventDataId] = savedEvent;
}
toast.success("Event updated successfully!");
// Call the onEventSaved callback if provided
if (onEventSaved) onEventSaved();
// Close the modal
handleModalClose();
} else {
// We're creating a new event
// Create the event first without files
const { files, ...cleanPayload } = updatedEvent;
const newEvent = await services.update.create<Event>(
Collections.EVENTS,
cleanPayload
);
// Then upload files if any
if (selectedFiles.size > 0 && newEvent?.id) {
console.log(`Adding ${selectedFiles.size} files to new event ${newEvent.id}`);
// Convert Map to array of File objects
const newFiles = Array.from(selectedFiles.values());
// Upload files to the new event
for (const file of newFiles) {
await services.fileManager.uploadFile(
Collections.EVENTS,
newEvent.id,
'files',
file,
true // Set append mode to true
);
}
}
// Clear cache to ensure fresh data
const dataSync = DataSyncService.getInstance();
await dataSync.clearCache();
toast.success("Event created successfully!");
// Call the onEventSaved callback if provided
if (onEventSaved) onEventSaved();
// Close the modal
handleModalClose();
}
// Refresh events list if available
if (window.fetchEvents) window.fetchEvents();
} catch (error) {
console.error("Failed to save event:", error);
toast.error(`Failed to save event: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsSubmitting(false);
window.hideLoading?.();
}
}, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting, handleModalClose]);
return (
<dialog id="editEventModal" className="modal">
{showPreview ? (
<div className="modal-box max-w-4xl">
<div className="flex justify-between items-center mb-4">
<button
className="btn btn-ghost btn-sm"
onClick={() => setShowPreview(false)}
>
Close
</button>
</div>
<div className="relative">
<FilePreview
url={previewUrl}
filename={previewFilename}
isModal={false}
/>
</div>
</div>
) : (
<div className="modal-box max-w-2xl">
<EventForm
event={event}
setEvent={handleFieldChange}
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
filesToDelete={filesToDelete}
setFilesToDelete={setFilesToDelete}
handlePreviewFile={handlePreviewFile}
isSubmitting={isSubmitting}
fileManager={services.fileManager}
onSubmit={handleSubmit}
onCancel={handleModalClose}
/>
</div>
)}
</dialog>
);
}