event attendees changes
still broken
This commit is contained in:
parent
9481f433de
commit
36e1f4663b
7 changed files with 2819 additions and 2400 deletions
|
@ -7,7 +7,7 @@ import { DataSyncService } from "../../../scripts/database/DataSyncService";
|
|||
import { Collections } from "../../../schemas/pocketbase/schema";
|
||||
import { Icon } from "@iconify/react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Event, AttendeeEntry } from "../../../schemas/pocketbase";
|
||||
import type { Event, EventAttendee } from "../../../schemas/pocketbase";
|
||||
|
||||
// Extended Event interface with additional properties needed for this component
|
||||
interface ExtendedEvent extends Event {
|
||||
|
@ -51,91 +51,152 @@ const EventCheckIn = () => {
|
|||
|
||||
// Log the check-in attempt
|
||||
await logger.send(
|
||||
"attempt",
|
||||
"info",
|
||||
"event_check_in",
|
||||
`User ${currentUser.id} attempted to check in with code: ${eventCode}`
|
||||
`Attempting to check in with code: ${eventCode}`
|
||||
);
|
||||
|
||||
// SECURITY FIX: Instead of syncing and querying IndexedDB with the event code,
|
||||
// directly query PocketBase for the event with the given code
|
||||
// This prevents the event code from being stored in IndexedDB
|
||||
const pb = auth.getPocketBase();
|
||||
const records = await pb.collection(Collections.EVENTS).getList(1, 1, {
|
||||
filter: `event_code = "${eventCode}"`,
|
||||
});
|
||||
|
||||
// Convert the first result to our Event type
|
||||
let event: Event | null = null;
|
||||
if (records.items.length > 0) {
|
||||
event = Get.convertUTCToLocal(records.items[0] as unknown as Event);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
// Validate event code
|
||||
if (!eventCode || eventCode.trim() === "") {
|
||||
await logger.send(
|
||||
"error",
|
||||
"event_check_in",
|
||||
`Check-in failed: Invalid event code "${eventCode}"`
|
||||
"Check-in failed: Empty event code"
|
||||
);
|
||||
throw new Error("Invalid event code");
|
||||
toast.error("Please enter an event code");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is already checked in
|
||||
const attendees = event.attendees || [];
|
||||
if (attendees.some((entry) => entry.user_id === currentUser.id)) {
|
||||
// Get event by code
|
||||
const events = await get.getList<Event>(
|
||||
Collections.EVENTS,
|
||||
1,
|
||||
1,
|
||||
`event_code="${eventCode}"`
|
||||
);
|
||||
|
||||
if (events.totalItems === 0) {
|
||||
await logger.send(
|
||||
"error",
|
||||
"event_check_in",
|
||||
`Check-in failed: User ${currentUser.id} already checked in to event ${event.event_name} (${event.id})`
|
||||
`Check-in failed: Invalid event code: ${eventCode}`
|
||||
);
|
||||
throw new Error("You have already checked in to this event");
|
||||
toast.error("Invalid event code. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
const event = events.items[0];
|
||||
|
||||
// Check if event is published
|
||||
if (!event.published) {
|
||||
await logger.send(
|
||||
"error",
|
||||
"event_check_in",
|
||||
`Check-in failed: Event not published: ${event.event_name}`
|
||||
);
|
||||
toast.error("This event is not currently available for check-in");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the event is active (has started and hasn't ended yet)
|
||||
const currentTime = new Date();
|
||||
const eventStartDate = new Date(event.start_date); // Now properly converted to local time by Get
|
||||
const eventEndDate = new Date(event.end_date); // Now properly converted to local time by Get
|
||||
const eventStartDate = new Date(event.start_date);
|
||||
const eventEndDate = new Date(event.end_date);
|
||||
|
||||
if (eventStartDate > currentTime) {
|
||||
if (currentTime < eventStartDate) {
|
||||
await logger.send(
|
||||
"error",
|
||||
"event_check_in",
|
||||
`Check-in failed: Event ${event.event_name} (${event.id}) has not started yet`
|
||||
`Check-in failed: Event has not started yet: ${event.event_name}`
|
||||
);
|
||||
throw new Error("This event has not started yet");
|
||||
toast.error(`This event hasn't started yet. It begins on ${eventStartDate.toLocaleDateString()} at ${eventStartDate.toLocaleTimeString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventEndDate < currentTime) {
|
||||
if (currentTime > eventEndDate) {
|
||||
await logger.send(
|
||||
"error",
|
||||
"event_check_in",
|
||||
`Check-in failed: Event ${event.event_name} (${event.id}) has already ended`
|
||||
`Check-in failed: Event has already ended: ${event.event_name}`
|
||||
);
|
||||
throw new Error("This event has already ended");
|
||||
toast.error("This event has already ended");
|
||||
return;
|
||||
}
|
||||
|
||||
// Log successful validation before proceeding
|
||||
// Check if user is already checked in
|
||||
const attendees = await get.getList<EventAttendee>(
|
||||
Collections.EVENT_ATTENDEES,
|
||||
1,
|
||||
1,
|
||||
`user="${currentUser.id}" && event="${event.id}"`
|
||||
);
|
||||
|
||||
if (attendees.totalItems > 0) {
|
||||
await logger.send(
|
||||
"error",
|
||||
"event_check_in",
|
||||
`Check-in failed: Already checked in to event: ${event.event_name}`
|
||||
);
|
||||
toast.error("You have already checked in to this event");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set current event for check-in
|
||||
setCurrentCheckInEvent(event);
|
||||
|
||||
// Log successful event lookup
|
||||
await logger.send(
|
||||
"info",
|
||||
"event_check_in",
|
||||
`Check-in validation successful for user ${currentUser.id} to event ${event.event_name} (${event.id})`
|
||||
`Found event for check-in: ${event.event_name}`
|
||||
);
|
||||
|
||||
// Store event code in local storage for offline check-in
|
||||
await dataSync.storeEventCode(eventCode);
|
||||
|
||||
// Show event details toast only for non-food events
|
||||
// For food events, we'll show the toast after food selection
|
||||
if (!event.has_food) {
|
||||
toast.success(
|
||||
<div>
|
||||
<strong>Event found!</strong>
|
||||
<p className="text-sm mt-1">{event.event_name}</p>
|
||||
<p className="text-xs mt-1">
|
||||
{event.points_to_reward > 0 ? `${event.points_to_reward} points` : "No points"}
|
||||
</p>
|
||||
</div>,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
}
|
||||
|
||||
// If event has food, show food selection modal
|
||||
if (event.has_food) {
|
||||
setCurrentCheckInEvent(event);
|
||||
// Show food-specific toast
|
||||
toast.success(
|
||||
<div>
|
||||
<strong>Event with food found!</strong>
|
||||
<p className="text-sm mt-1">{event.event_name}</p>
|
||||
<p className="text-xs mt-1">Please select your food preference</p>
|
||||
</div>,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
|
||||
const modal = document.getElementById("foodSelectionModal") as HTMLDialogElement;
|
||||
modal.showModal();
|
||||
if (modal) modal.showModal();
|
||||
} else {
|
||||
// If no food, complete check-in directly
|
||||
await completeCheckIn(event, null);
|
||||
// If no food, show confirmation modal
|
||||
const modal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
|
||||
if (modal) modal.showModal();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "Failed to check in to event");
|
||||
console.error("Error checking in:", error);
|
||||
toast.error(error.message || "An error occurred during check-in");
|
||||
}
|
||||
}
|
||||
|
||||
async function completeCheckIn(event: Event, foodSelection: string | null): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const auth = Authentication.getInstance();
|
||||
const update = Update.getInstance();
|
||||
const logger = SendLog.getInstance();
|
||||
|
@ -146,119 +207,108 @@ const EventCheckIn = () => {
|
|||
throw new Error("You must be logged in to check in to events");
|
||||
}
|
||||
|
||||
// Check if user is already checked in
|
||||
const userId = auth.getUserId();
|
||||
|
||||
if (!userId) {
|
||||
toast.error("You must be logged in to check in to an event");
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize attendees array if it doesn't exist
|
||||
const attendees = event.attendees || [];
|
||||
const userId = currentUser.id;
|
||||
const eventId = event.id;
|
||||
|
||||
// Check if user is already checked in
|
||||
const isAlreadyCheckedIn = attendees.some(
|
||||
(attendee) => attendee.user_id === userId
|
||||
const get = Get.getInstance();
|
||||
const existingAttendees = await get.getList<EventAttendee>(
|
||||
Collections.EVENT_ATTENDEES,
|
||||
1,
|
||||
1,
|
||||
`user="${userId}" && event="${eventId}"`
|
||||
);
|
||||
|
||||
const isAlreadyCheckedIn = existingAttendees.totalItems > 0;
|
||||
|
||||
if (isAlreadyCheckedIn) {
|
||||
toast("You are already checked in to this event", {
|
||||
icon: '⚠️',
|
||||
style: {
|
||||
borderRadius: '10px',
|
||||
background: '#FFC107',
|
||||
color: '#000',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create attendee entry with check-in details
|
||||
const attendeeEntry: AttendeeEntry = {
|
||||
user_id: currentUser.id,
|
||||
time_checked_in: new Date().toISOString(), // Will be properly converted to UTC by Update
|
||||
food: foodSelection || "none",
|
||||
};
|
||||
|
||||
// Get existing attendees or initialize empty array
|
||||
const existingAttendees = event.attendees || [];
|
||||
|
||||
// Check if user is already checked in
|
||||
if (existingAttendees.some((entry) => entry.user_id === currentUser.id)) {
|
||||
throw new Error("You have already checked in to this event");
|
||||
}
|
||||
|
||||
// Add new attendee entry to the array
|
||||
const updatedAttendees = [...existingAttendees, attendeeEntry];
|
||||
// Create new attendee record
|
||||
const attendeeData = {
|
||||
user: userId,
|
||||
event: eventId,
|
||||
food_ate: foodSelection || "",
|
||||
time_checked_in: new Date().toISOString(),
|
||||
points_earned: event.points_to_reward || 0
|
||||
};
|
||||
|
||||
// Update attendees array with the new entry
|
||||
await update.updateField("events", event.id, "attendees", updatedAttendees);
|
||||
// Create the attendee record using PocketBase's create method
|
||||
// This will properly use the collection rules defined in PocketBase
|
||||
try {
|
||||
// Use the update.create method which calls PocketBase's collection.create method
|
||||
await update.create(Collections.EVENT_ATTENDEES, attendeeData);
|
||||
|
||||
// SECURITY FIX: Instead of syncing the entire events collection which would store event codes in IndexedDB,
|
||||
// only sync the user's collection to update their points
|
||||
if (event.points_to_reward > 0) {
|
||||
await dataSync.syncCollection(Collections.USERS);
|
||||
console.log("Successfully created attendance record");
|
||||
} catch (createError: any) {
|
||||
console.error("Error creating attendance record:", createError);
|
||||
|
||||
// Check if this is a duplicate record error
|
||||
if (createError.status === 400 && createError.data?.data?.user?.code === "validation_not_unique") {
|
||||
throw new Error("You have already checked in to this event");
|
||||
}
|
||||
|
||||
throw createError;
|
||||
}
|
||||
|
||||
// If food selection was made, log it
|
||||
if (foodSelection) {
|
||||
await logger.send(
|
||||
"update",
|
||||
"event check-in",
|
||||
`Food selection for ${event.event_name}: ${foodSelection}`
|
||||
);
|
||||
}
|
||||
|
||||
// Award points to user if available
|
||||
if (event.points_to_reward > 0) {
|
||||
const userPoints = currentUser.points || 0;
|
||||
await update.updateField(
|
||||
"users",
|
||||
currentUser.id,
|
||||
"points",
|
||||
userPoints + event.points_to_reward
|
||||
);
|
||||
|
||||
// Log the points award
|
||||
await logger.send(
|
||||
"update",
|
||||
"event check-in",
|
||||
`Awarded ${event.points_to_reward} points for checking in to ${event.event_name}`
|
||||
);
|
||||
}
|
||||
|
||||
// Show success message with points if awarded
|
||||
toast.success(
|
||||
`Successfully checked in to ${event.event_name}${event.points_to_reward > 0
|
||||
? ` (+${event.points_to_reward} points!)`
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
|
||||
// Log the check-in
|
||||
// Log successful check-in
|
||||
await logger.send(
|
||||
"check_in",
|
||||
"events",
|
||||
`Checked in to event ${event.event_name}`
|
||||
"info",
|
||||
"event_check_in",
|
||||
`Successfully checked in to event: ${event.event_name}`
|
||||
);
|
||||
|
||||
// Close the food selection modal if it's open
|
||||
const modal = document.getElementById("foodSelectionModal") as HTMLDialogElement;
|
||||
if (modal) {
|
||||
modal.close();
|
||||
setFoodInput("");
|
||||
}
|
||||
// Clear event code from local storage
|
||||
await dataSync.clearEventCode();
|
||||
|
||||
// Show success message with event name and points
|
||||
const pointsMessage = event.points_to_reward > 0
|
||||
? ` (+${event.points_to_reward} points!)`
|
||||
: "";
|
||||
toast.success(`Successfully checked in to ${event.event_name}${pointsMessage}`);
|
||||
setCurrentCheckInEvent(null);
|
||||
setFoodInput("");
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "Failed to check in to event");
|
||||
console.error("Error completing check-in:", error);
|
||||
toast.error(error.message || "An error occurred during check-in");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (currentCheckInEvent) {
|
||||
await completeCheckIn(currentCheckInEvent, foodInput.trim());
|
||||
setCurrentCheckInEvent(null);
|
||||
if (!currentCheckInEvent) return;
|
||||
|
||||
try {
|
||||
const auth = Authentication.getInstance();
|
||||
const logger = SendLog.getInstance();
|
||||
const get = Get.getInstance();
|
||||
|
||||
const currentUser = auth.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
throw new Error("You must be logged in to check in to events");
|
||||
}
|
||||
|
||||
// Get existing attendees or initialize empty array
|
||||
const existingAttendees = await get.getList<EventAttendee>(
|
||||
Collections.EVENT_ATTENDEES,
|
||||
1,
|
||||
1,
|
||||
`user="${currentUser.id}" && event="${currentCheckInEvent.id}"`
|
||||
);
|
||||
|
||||
// Check if user is already checked in
|
||||
if (existingAttendees.totalItems > 0) {
|
||||
throw new Error("You have already checked in to this event");
|
||||
}
|
||||
|
||||
// Complete check-in with food selection
|
||||
await completeCheckIn(currentCheckInEvent, foodInput);
|
||||
} catch (error: any) {
|
||||
console.error("Error submitting check-in:", error);
|
||||
toast.error(error.message || "An error occurred during check-in");
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -326,7 +376,13 @@ const EventCheckIn = () => {
|
|||
{/* Food Selection Modal */}
|
||||
<dialog id="foodSelectionModal" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg mb-4">Food Selection</h3>
|
||||
<h3 className="font-bold text-lg mb-2">{currentCheckInEvent?.event_name}</h3>
|
||||
<div className="text-sm mb-4 opacity-75">
|
||||
{currentCheckInEvent?.event_description}
|
||||
</div>
|
||||
<div className="badge badge-primary mb-4">
|
||||
{currentCheckInEvent?.points_to_reward} points
|
||||
</div>
|
||||
<p className="mb-4">This event has food! Please let us know what you'd like to eat:</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-control">
|
||||
|
@ -345,7 +401,17 @@ const EventCheckIn = () => {
|
|||
modal.close();
|
||||
setCurrentCheckInEvent(null);
|
||||
}}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary">Submit</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Icon
|
||||
icon="line-md:loading-twotone-loop"
|
||||
className="w-5 h-5"
|
||||
inline={true}
|
||||
/>
|
||||
) : (
|
||||
"Check In"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -353,6 +419,58 @@ const EventCheckIn = () => {
|
|||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
{/* Confirmation Modal (for events without food) */}
|
||||
<dialog id="confirmCheckInModal" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg mb-2">{currentCheckInEvent?.event_name}</h3>
|
||||
<div className="text-sm mb-4 opacity-75">
|
||||
{currentCheckInEvent?.event_description}
|
||||
</div>
|
||||
<div className="badge badge-primary mb-4">
|
||||
{currentCheckInEvent?.points_to_reward} points
|
||||
</div>
|
||||
<p className="mb-4">Are you sure you want to check in to this event?</p>
|
||||
<div className="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
const modal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
|
||||
modal.close();
|
||||
setCurrentCheckInEvent(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
if (currentCheckInEvent) {
|
||||
completeCheckIn(currentCheckInEvent, null);
|
||||
const modal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
|
||||
modal.close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Icon
|
||||
icon="line-md:loading-twotone-loop"
|
||||
className="w-5 h-5"
|
||||
inline={true}
|
||||
/>
|
||||
) : (
|
||||
"Confirm Check In"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@ import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
|||
import { DataSyncService } from "../../../scripts/database/DataSyncService";
|
||||
import { DexieService } from "../../../scripts/database/DexieService";
|
||||
import { Collections } from "../../../schemas/pocketbase/schema";
|
||||
import type { Event, AttendeeEntry } from "../../../schemas/pocketbase";
|
||||
import type { Event, AttendeeEntry, EventAttendee } from "../../../schemas/pocketbase";
|
||||
|
||||
// Extended Event interface with additional properties needed for this component
|
||||
interface ExtendedEvent extends Event {
|
||||
|
@ -132,76 +132,111 @@ const EventLoad = () => {
|
|||
);
|
||||
|
||||
const renderEventCard = (event: Event) => {
|
||||
const startDate = new Date(event.start_date);
|
||||
const endDate = new Date(event.end_date);
|
||||
const now = new Date();
|
||||
const isPastEvent = endDate < now;
|
||||
try {
|
||||
// Get authentication instance
|
||||
const auth = Authentication.getInstance();
|
||||
const currentUser = auth.getCurrentUser();
|
||||
|
||||
// Get current user to check attendance
|
||||
const auth = Authentication.getInstance();
|
||||
const currentUser = auth.getCurrentUser();
|
||||
const hasAttended = currentUser && event.attendees?.some(entry => entry.user_id === currentUser.id);
|
||||
// Check if user has attended this event by querying the event_attendees collection
|
||||
let hasAttended = false;
|
||||
if (currentUser) {
|
||||
// We'll check attendance status when displaying the card
|
||||
// This will be done asynchronously after rendering
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const get = Get.getInstance();
|
||||
const attendees = await get.getList<EventAttendee>(
|
||||
"event_attendees",
|
||||
1,
|
||||
1,
|
||||
`user="${currentUser.id}" && event="${event.id}"`
|
||||
);
|
||||
|
||||
// Store event data in window object with unique ID
|
||||
const eventDataId = `event_${event.id}`;
|
||||
window[eventDataId] = event;
|
||||
const hasAttendedEvent = attendees.totalItems > 0;
|
||||
|
||||
return (
|
||||
<div key={event.id} className="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-300 relative overflow-hidden">
|
||||
<div className="card-body p-3 sm:p-4">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex-1">
|
||||
<h3 className="card-title text-base sm:text-lg font-semibold mb-1 line-clamp-2">{event.event_name}</h3>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm text-base-content/70">
|
||||
<div className="badge badge-primary badge-sm">{event.points_to_reward} pts</div>
|
||||
<div className="text-xs sm:text-sm opacity-75">
|
||||
{startDate.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
{" • "}
|
||||
{startDate.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
// Update the card UI based on attendance status
|
||||
const cardElement = document.getElementById(`event-card-${event.id}`);
|
||||
if (cardElement && hasAttendedEvent) {
|
||||
const attendedBadge = cardElement.querySelector('.attended-badge');
|
||||
if (attendedBadge) {
|
||||
(attendedBadge as HTMLElement).style.display = 'flex';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking attendance status:", error);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Store event data in window object with unique ID
|
||||
const eventDataId = `event_${event.id}`;
|
||||
window[eventDataId] = event;
|
||||
|
||||
const startDate = new Date(event.start_date);
|
||||
const endDate = new Date(event.end_date);
|
||||
const now = new Date();
|
||||
const isPastEvent = endDate < now;
|
||||
|
||||
return (
|
||||
<div key={event.id} className="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-300 relative overflow-hidden">
|
||||
<div className="card-body p-3 sm:p-4">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex-1">
|
||||
<h3 className="card-title text-base sm:text-lg font-semibold mb-1 line-clamp-2">{event.event_name}</h3>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm text-base-content/70">
|
||||
<div className="badge badge-primary badge-sm">{event.points_to_reward} pts</div>
|
||||
<div className="text-xs sm:text-sm opacity-75">
|
||||
{startDate.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
{" • "}
|
||||
{startDate.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs sm:text-sm text-base-content/70 my-2 line-clamp-2">
|
||||
{event.event_description || "No description available"}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-base-content/70 my-2 line-clamp-2">
|
||||
{event.event_description || "No description available"}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-auto pt-2">
|
||||
{event.files && event.files.length > 0 && (
|
||||
<button
|
||||
onClick={() => window.openDetailsModal(event as ExtendedEvent)}
|
||||
className="btn btn-ghost btn-sm text-xs sm:text-sm gap-1 h-8 min-h-0 px-2"
|
||||
>
|
||||
<Icon icon="heroicons:document-duplicate" className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
Files ({event.files.length})
|
||||
</button>
|
||||
)}
|
||||
{isPastEvent && (
|
||||
<div className={`badge ${hasAttended ? 'badge-success' : 'badge-ghost'} text-xs gap-1`}>
|
||||
<Icon
|
||||
icon={hasAttended ? "heroicons:check-circle" : "heroicons:x-circle"}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{hasAttended ? 'Attended' : 'Not Attended'}
|
||||
<div className="flex flex-wrap items-center gap-2 mt-auto pt-2">
|
||||
{event.files && event.files.length > 0 && (
|
||||
<button
|
||||
onClick={() => window.openDetailsModal(event as ExtendedEvent)}
|
||||
className="btn btn-ghost btn-sm text-xs sm:text-sm gap-1 h-8 min-h-0 px-2"
|
||||
>
|
||||
<Icon icon="heroicons:document-duplicate" className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
Files ({event.files.length})
|
||||
</button>
|
||||
)}
|
||||
{isPastEvent && (
|
||||
<div className={`badge ${hasAttended ? 'badge-success' : 'badge-ghost'} text-xs gap-1`}>
|
||||
<Icon
|
||||
icon={hasAttended ? "heroicons:check-circle" : "heroicons:x-circle"}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{hasAttended ? 'Attended' : 'Not Attended'}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs sm:text-sm opacity-75 ml-auto">
|
||||
{event.location}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs sm:text-sm opacity-75 ml-auto">
|
||||
{event.location}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error rendering event card:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadEvents = async () => {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -5,13 +5,22 @@ import { SendLog } from '../../../scripts/pocketbase/SendLog';
|
|||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import { Icon } from "@iconify/react";
|
||||
import type { Event, AttendeeEntry, User as SchemaUser } from "../../../schemas/pocketbase";
|
||||
import type { Event, User as SchemaUser, EventAttendee } from "../../../schemas/pocketbase";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// Extended User interface with additional properties needed for this component
|
||||
interface User extends SchemaUser {
|
||||
member_type: string;
|
||||
}
|
||||
|
||||
// Define AttendeeEntry interface locally
|
||||
interface AttendeeEntry {
|
||||
user_id: string;
|
||||
time_checked_in: string;
|
||||
food: string;
|
||||
points_earned?: number;
|
||||
}
|
||||
|
||||
// Cache for storing user data
|
||||
const userCache = new Map<string, {
|
||||
data: User;
|
||||
|
@ -58,7 +67,6 @@ const HighlightText = ({ text, searchTerms }: { text: string | number | null | u
|
|||
interface EventFields {
|
||||
id: true;
|
||||
event_name: true;
|
||||
attendees: true;
|
||||
}
|
||||
|
||||
interface UserFields {
|
||||
|
@ -73,7 +81,7 @@ interface UserFields {
|
|||
}
|
||||
|
||||
// Constants for field selection
|
||||
const EVENT_FIELDS: (keyof EventFields)[] = ['id', 'event_name', 'attendees'];
|
||||
const EVENT_FIELDS: (keyof EventFields)[] = ['id', 'event_name'];
|
||||
const USER_FIELDS: (keyof UserFields)[] = ['id', 'name', 'email', 'pid', 'member_id', 'member_type', 'graduation_year', 'major'];
|
||||
|
||||
export default function Attendees() {
|
||||
|
@ -86,6 +94,7 @@ export default function Attendees() {
|
|||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [processedSearchTerms, setProcessedSearchTerms] = useState<string[]>([]);
|
||||
const [refreshKey, setRefreshKey] = useState(0); // Add a refresh key to force re-fetching
|
||||
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
@ -141,6 +150,8 @@ export default function Attendees() {
|
|||
|
||||
// Optimized user data fetching with cache
|
||||
const fetchUserData = useCallback(async (userIds: string[]) => {
|
||||
if (!userIds.length) return new Map<string, User>();
|
||||
|
||||
const now = Date.now();
|
||||
const uncachedIds: string[] = [];
|
||||
const cachedUsers = new Map<string, User>();
|
||||
|
@ -160,48 +171,86 @@ export default function Attendees() {
|
|||
return cachedUsers;
|
||||
}
|
||||
|
||||
// Fetch uncached users
|
||||
try {
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
// Create a filter to get all uncached users in one request
|
||||
const userFilter = uncachedIds.map(id => `id="${id}"`).join(" || ");
|
||||
|
||||
// Sync users collection for the uncached IDs
|
||||
if (uncachedIds.length > 0) {
|
||||
const idFilter = uncachedIds.map(id => `id = "${id}"`).join(' || ');
|
||||
await dataSync.syncCollection(Collections.USERS, idFilter);
|
||||
}
|
||||
|
||||
// Get users from IndexedDB
|
||||
const users = await Promise.all(
|
||||
uncachedIds.map(async id => {
|
||||
try {
|
||||
return await dataSync.getItem<User>(Collections.USERS, id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch user ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
// Fetch all uncached users in one request
|
||||
const usersResponse = await get.getAll<User>(
|
||||
Collections.USERS,
|
||||
userFilter
|
||||
);
|
||||
|
||||
// Update cache and merge with cached users
|
||||
users.forEach(user => {
|
||||
if (user) {
|
||||
userCache.set(user.id, { data: user, timestamp: now });
|
||||
cachedUsers.set(user.id, user);
|
||||
// Process the fetched users
|
||||
usersResponse.forEach(user => {
|
||||
// Add member_type if it doesn't exist
|
||||
const userWithMemberType = {
|
||||
...user,
|
||||
member_type: user.member_type || "N/A"
|
||||
};
|
||||
cachedUsers.set(user.id, userWithMemberType);
|
||||
|
||||
// Update cache
|
||||
userCache.set(user.id, {
|
||||
data: userWithMemberType,
|
||||
timestamp: now
|
||||
});
|
||||
});
|
||||
|
||||
// Create placeholders for any users that weren't found
|
||||
const fetchedIds = new Set(usersResponse.map(user => user.id));
|
||||
uncachedIds.forEach(id => {
|
||||
if (!fetchedIds.has(id) && !cachedUsers.has(id)) {
|
||||
// Create a placeholder user
|
||||
const placeholderUser: User = {
|
||||
id,
|
||||
name: `User ${id}`,
|
||||
email: "N/A",
|
||||
emailVisibility: false,
|
||||
verified: false,
|
||||
created: "",
|
||||
updated: "",
|
||||
member_type: "N/A"
|
||||
};
|
||||
cachedUsers.set(id, placeholderUser);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch uncached users:', error);
|
||||
console.error('Failed to fetch users:', error);
|
||||
|
||||
// Create placeholders for all uncached users that failed to fetch
|
||||
uncachedIds.forEach(id => {
|
||||
if (!cachedUsers.has(id)) {
|
||||
const placeholderUser: User = {
|
||||
id,
|
||||
name: `User ${id}`,
|
||||
email: "N/A",
|
||||
emailVisibility: false,
|
||||
verified: false,
|
||||
created: "",
|
||||
updated: "",
|
||||
member_type: "N/A"
|
||||
};
|
||||
cachedUsers.set(id, placeholderUser);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return cachedUsers;
|
||||
}, []);
|
||||
|
||||
// Function to refresh attendees data
|
||||
const refreshAttendees = useCallback(() => {
|
||||
setRefreshKey(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Listen for the custom event
|
||||
useEffect(() => {
|
||||
const handleUpdateAttendees = async (e: CustomEvent<{ eventId: string; eventName: string }>) => {
|
||||
setEventId(e.detail.eventId);
|
||||
setEventName(e.detail.eventName);
|
||||
setCurrentPage(1); // Reset pagination on new event
|
||||
setSearchTerm(''); // Clear search on new event
|
||||
|
||||
// Log the attendees view action
|
||||
try {
|
||||
|
@ -217,17 +266,22 @@ export default function Attendees() {
|
|||
};
|
||||
|
||||
window.addEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener);
|
||||
|
||||
// Expose refresh function to window
|
||||
(window as any).refreshAttendees = refreshAttendees;
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener);
|
||||
delete (window as any).refreshAttendees;
|
||||
};
|
||||
}, []);
|
||||
}, [refreshAttendees]);
|
||||
|
||||
// Update search terms when search input changes
|
||||
useEffect(() => {
|
||||
updateProcessedSearchTerms(searchTerm);
|
||||
}, [searchTerm, updateProcessedSearchTerms]);
|
||||
|
||||
// Fetch event data when eventId changes
|
||||
// Fetch event data when eventId changes or refreshKey changes
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const fetchEventData = async () => {
|
||||
|
@ -243,44 +297,118 @@ export default function Attendees() {
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (!eventId) {
|
||||
setAttendeesList([]);
|
||||
setUsers(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear cache to ensure fresh data
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.clearCache();
|
||||
await dataSync.syncCollection(Collections.EVENTS, `id="${eventId}"`);
|
||||
|
||||
// Sync the event data
|
||||
await dataSync.syncCollection(Collections.EVENTS, `id = "${eventId}"`);
|
||||
|
||||
// Get the event from IndexedDB
|
||||
const event = await dataSync.getItem<Event>(Collections.EVENTS, eventId);
|
||||
|
||||
if (!isMounted) return;
|
||||
const event = await get.getOne<Event>(Collections.EVENTS, eventId);
|
||||
|
||||
if (!event) {
|
||||
setError('Event not found');
|
||||
setError("Event not found");
|
||||
setAttendeesList([]);
|
||||
setUsers(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.attendees?.length) {
|
||||
setAttendeesList([]);
|
||||
setUsers(new Map());
|
||||
// Fetch attendees from event_attendees collection with a higher limit
|
||||
const attendeesList = await get.getList<EventAttendee>(
|
||||
Collections.EVENT_ATTENDEES,
|
||||
1,
|
||||
2000, // Increased limit to handle more attendees
|
||||
`event="${eventId}"`
|
||||
);
|
||||
|
||||
if (!attendeesList.items.length) {
|
||||
if (isMounted) {
|
||||
setAttendeesList([]);
|
||||
setUsers(new Map());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setAttendeesList(event.attendees);
|
||||
|
||||
// Fetch user details with cache
|
||||
const userIds = [...new Set(event.attendees.map(a => a.user_id))];
|
||||
const userMap = await fetchUserData(userIds);
|
||||
// Transform EventAttendee records to match the expected format
|
||||
const transformedAttendees = attendeesList.items.map(attendee => ({
|
||||
user_id: attendee.user, // This is the user ID (relation)
|
||||
time_checked_in: attendee.time_checked_in,
|
||||
food: attendee.food_ate,
|
||||
points_earned: attendee.points_earned
|
||||
}));
|
||||
|
||||
if (isMounted) {
|
||||
setUsers(userMap);
|
||||
setAttendeesList(transformedAttendees);
|
||||
}
|
||||
|
||||
// Fetch all users at once to improve performance
|
||||
const userIds = transformedAttendees.map(a => a.user_id);
|
||||
|
||||
// Create a filter to get all users in one request
|
||||
const userFilter = userIds.map(id => `id="${id}"`).join(" || ");
|
||||
|
||||
try {
|
||||
// Fetch all users directly from PocketBase in one request
|
||||
const usersResponse = await get.getAll<User>(
|
||||
Collections.USERS,
|
||||
userFilter
|
||||
);
|
||||
|
||||
// Create a map of users
|
||||
const userMap = new Map<string, User>();
|
||||
usersResponse.forEach(user => {
|
||||
// Add member_type if it doesn't exist
|
||||
const userWithMemberType = {
|
||||
...user,
|
||||
member_type: user.member_type || "N/A"
|
||||
};
|
||||
userMap.set(user.id, userWithMemberType);
|
||||
|
||||
// Update cache
|
||||
userCache.set(user.id, {
|
||||
data: userWithMemberType,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// For any missing users, create placeholders
|
||||
userIds.forEach(id => {
|
||||
if (!userMap.has(id)) {
|
||||
const placeholderUser: User = {
|
||||
id,
|
||||
name: `User ${id}`,
|
||||
email: "N/A",
|
||||
emailVisibility: false,
|
||||
verified: false,
|
||||
created: "",
|
||||
updated: "",
|
||||
member_type: "N/A"
|
||||
};
|
||||
userMap.set(id, placeholderUser);
|
||||
}
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
setUsers(userMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error);
|
||||
|
||||
// Fallback to individual user fetching
|
||||
const userMap = await fetchUserData(userIds);
|
||||
if (isMounted) {
|
||||
setUsers(userMap);
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`Loaded ${attendeesList.items.length} attendees for ${event.event_name}`);
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
console.error('Failed to fetch event data:', error);
|
||||
setError('Failed to load event data');
|
||||
setAttendeesList([]);
|
||||
}
|
||||
console.error("Error fetching event data:", error);
|
||||
setError("Failed to load event data. Please try refreshing.");
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
|
@ -290,15 +418,18 @@ export default function Attendees() {
|
|||
|
||||
fetchEventData();
|
||||
return () => { isMounted = false; };
|
||||
}, [eventId, auth, fetchUserData]);
|
||||
}, [eventId, auth, fetchUserData, refreshKey]);
|
||||
|
||||
// Reset state when modal is closed
|
||||
useEffect(() => {
|
||||
const handleModalClose = () => {
|
||||
setEventId('');
|
||||
setEventName('');
|
||||
setAttendeesList([]);
|
||||
setUsers(new Map());
|
||||
setError(null);
|
||||
setSearchTerm('');
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const modal = document.getElementById('attendeesModal');
|
||||
|
@ -332,15 +463,22 @@ export default function Attendees() {
|
|||
'Graduation Year',
|
||||
'Major',
|
||||
'Check-in Time',
|
||||
'Food Choice'
|
||||
'Food Choice',
|
||||
'Points Earned'
|
||||
].map(escapeCSV);
|
||||
|
||||
// Create CSV rows
|
||||
const rows = attendeesList.map(attendee => {
|
||||
const user = users.get(attendee.user_id);
|
||||
const checkInTime = new Date(attendee.time_checked_in).toLocaleString();
|
||||
let checkInTime = '';
|
||||
try {
|
||||
checkInTime = new Date(attendee.time_checked_in).toLocaleString();
|
||||
} catch (e) {
|
||||
checkInTime = attendee.time_checked_in || 'N/A';
|
||||
}
|
||||
|
||||
return [
|
||||
user?.name || 'Unknown User',
|
||||
user?.name || `User ${attendee.user_id}`,
|
||||
user?.email || 'N/A',
|
||||
user?.pid || 'N/A',
|
||||
user?.member_id || 'N/A',
|
||||
|
@ -348,7 +486,8 @@ export default function Attendees() {
|
|||
user?.graduation_year || 'N/A',
|
||||
user?.major || 'N/A',
|
||||
checkInTime,
|
||||
attendee.food || 'N/A'
|
||||
attendee.food || 'N/A',
|
||||
attendee.points_earned || 'N/A'
|
||||
].map(escapeCSV);
|
||||
});
|
||||
|
||||
|
@ -375,6 +514,8 @@ export default function Attendees() {
|
|||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url); // Clean up the URL object
|
||||
|
||||
toast.success(`Downloaded ${rows.length} attendee records`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
@ -390,6 +531,13 @@ export default function Attendees() {
|
|||
<div className="alert alert-error">
|
||||
<Icon icon="heroicons:exclamation-circle" className="h-6 w-6" />
|
||||
<span>{error}</span>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={refreshAttendees}
|
||||
>
|
||||
<Icon icon="heroicons:arrow-path" className="h-4 w-4 mr-1" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -403,6 +551,13 @@ export default function Attendees() {
|
|||
<div className="text-center py-8 text-base-content/70">
|
||||
<Icon icon="heroicons:user-group" className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No attendees yet</p>
|
||||
<button
|
||||
className="btn btn-sm btn-outline mt-4"
|
||||
onClick={refreshAttendees}
|
||||
>
|
||||
<Icon icon="heroicons:arrow-path" className="h-4 w-4 mr-1" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -426,13 +581,22 @@ export default function Attendees() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm gap-2"
|
||||
onClick={downloadAttendeesCSV}
|
||||
>
|
||||
<Icon icon="heroicons:arrow-down-tray" className="h-4 w-4" />
|
||||
Download CSV
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="btn btn-outline btn-sm gap-2"
|
||||
onClick={refreshAttendees}
|
||||
>
|
||||
<Icon icon="heroicons:arrow-path" className="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm gap-2"
|
||||
onClick={downloadAttendeesCSV}
|
||||
>
|
||||
<Icon icon="heroicons:arrow-down-tray" className="h-4 w-4" />
|
||||
Download CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
|
@ -462,16 +626,22 @@ export default function Attendees() {
|
|||
<th className="bg-base-100">Major</th>
|
||||
<th className="bg-base-100">Check-in Time</th>
|
||||
<th className="bg-base-100">Food Choice</th>
|
||||
<th className="bg-base-100">Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedAttendees.map((attendee, index) => {
|
||||
const user = users.get(attendee.user_id);
|
||||
const checkInTime = new Date(attendee.time_checked_in).toLocaleString();
|
||||
let checkInTime = '';
|
||||
try {
|
||||
checkInTime = new Date(attendee.time_checked_in).toLocaleString();
|
||||
} catch (e) {
|
||||
checkInTime = attendee.time_checked_in || 'N/A';
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={`${attendee.user_id}-${index}`}>
|
||||
<td><HighlightText text={user?.name || 'Unknown User'} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={user?.name || `User ${attendee.user_id}`} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={user?.email || 'N/A'} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={user?.pid || 'N/A'} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={user?.member_id || 'N/A'} searchTerms={processedSearchTerms} /></td>
|
||||
|
@ -480,6 +650,7 @@ export default function Attendees() {
|
|||
<td><HighlightText text={user?.major || 'N/A'} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={checkInTime} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={attendee.food || 'N/A'} searchTerms={processedSearchTerms} /></td>
|
||||
<td><HighlightText text={attendee.points_earned || 'N/A'} searchTerms={processedSearchTerms} /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -9,6 +9,7 @@ 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.
|
||||
|
@ -510,18 +511,19 @@ const ErrorDisplay = memo(({ error, onRetry }: { error: string; onRetry: () => v
|
|||
export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||
// State for form data and UI
|
||||
const [event, setEvent] = useState<Event>({
|
||||
id: '',
|
||||
event_name: '',
|
||||
event_description: '',
|
||||
event_code: '',
|
||||
location: '',
|
||||
id: "",
|
||||
created: "",
|
||||
updated: "",
|
||||
event_name: "",
|
||||
event_description: "",
|
||||
event_code: "",
|
||||
location: "",
|
||||
files: [],
|
||||
points_to_reward: 0,
|
||||
start_date: Get.formatLocalDate(new Date(), false),
|
||||
end_date: Get.formatLocalDate(new Date(), false),
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
published: false,
|
||||
has_food: false,
|
||||
attendees: []
|
||||
has_food: false
|
||||
});
|
||||
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
|
@ -557,7 +559,16 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
const initializeEventData = useCallback(async (eventId: string) => {
|
||||
try {
|
||||
if (eventId) {
|
||||
const eventData = await services.get.getOne<Event>("events", 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) {
|
||||
|
@ -573,20 +584,22 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
}
|
||||
|
||||
setEvent(eventData);
|
||||
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: Get.formatLocalDate(new Date(), false),
|
||||
end_date: Get.formatLocalDate(new Date(), false),
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
published: false,
|
||||
has_food: false,
|
||||
attendees: []
|
||||
has_food: false
|
||||
});
|
||||
}
|
||||
setSelectedFiles(new Map());
|
||||
|
@ -595,7 +608,7 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
setHasUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize event data:", error);
|
||||
alert("Failed to load event data. Please try again.");
|
||||
toast.error("Failed to load event data. Please try again.");
|
||||
}
|
||||
}, [services.get]);
|
||||
|
||||
|
@ -614,7 +627,7 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
modal.showModal();
|
||||
} catch (error) {
|
||||
console.error("Failed to open edit modal:", error);
|
||||
alert("Failed to open edit modal. Please try again.");
|
||||
toast.error("Failed to open edit modal. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -637,23 +650,25 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
}
|
||||
|
||||
setEvent({
|
||||
id: '',
|
||||
event_name: '',
|
||||
event_description: '',
|
||||
event_code: '',
|
||||
location: '',
|
||||
id: "",
|
||||
created: "",
|
||||
updated: "",
|
||||
event_name: "",
|
||||
event_description: "",
|
||||
event_code: "",
|
||||
location: "",
|
||||
files: [],
|
||||
points_to_reward: 0,
|
||||
start_date: Get.formatLocalDate(new Date(), false),
|
||||
end_date: Get.formatLocalDate(new Date(), false),
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
published: false,
|
||||
has_food: false,
|
||||
attendees: []
|
||||
has_food: false
|
||||
});
|
||||
setSelectedFiles(new Map());
|
||||
setFilesToDelete(new Set());
|
||||
setShowPreview(false);
|
||||
setHasUnsavedChanges(false);
|
||||
setPreviewUrl("");
|
||||
setPreviewFilename("");
|
||||
|
||||
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
|
||||
if (modal) modal.close();
|
||||
|
@ -663,176 +678,160 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
e.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
|
||||
const form = e.target as HTMLFormElement;
|
||||
const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
const cancelButton = form.querySelector('button[type="button"]') as HTMLButtonElement;
|
||||
|
||||
setIsSubmitting(true);
|
||||
if (submitButton) submitButton.disabled = true;
|
||||
if (cancelButton) cancelButton.disabled = true;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
window.showLoading?.();
|
||||
const pb = services.auth.getPocketBase();
|
||||
|
||||
console.log('Form submission started');
|
||||
console.log('Event data:', event);
|
||||
const submitButton = document.getElementById("submitEventButton") as HTMLButtonElement;
|
||||
const cancelButton = document.getElementById("cancelEventButton") as HTMLButtonElement;
|
||||
|
||||
const formData = new FormData(form);
|
||||
const eventData = {
|
||||
event_name: formData.get("editEventName"),
|
||||
event_code: formData.get("editEventCode"),
|
||||
event_description: formData.get("editEventDescription"),
|
||||
location: formData.get("editEventLocation"),
|
||||
points_to_reward: Number(formData.get("editEventPoints")),
|
||||
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: Event = {
|
||||
id: event.id,
|
||||
created: event.created,
|
||||
updated: event.updated,
|
||||
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",
|
||||
attendees: event.attendees || []
|
||||
has_food: formData.get("editEventHasFood") === "on"
|
||||
};
|
||||
|
||||
// Log the update attempt
|
||||
await services.sendLog.send(
|
||||
"update",
|
||||
"event",
|
||||
`Updating event: ${updatedEvent.event_name} (${updatedEvent.id})`
|
||||
);
|
||||
|
||||
// Process file changes
|
||||
const uploadQueue = new UploadQueue();
|
||||
const fileChanges: FileChanges = {
|
||||
added: selectedFiles,
|
||||
deleted: filesToDelete,
|
||||
unchanged: event.files?.filter(file => !filesToDelete.has(file)) || []
|
||||
};
|
||||
|
||||
// Handle file deletions
|
||||
if (fileChanges.deleted.size > 0) {
|
||||
for (const fileId of fileChanges.deleted) {
|
||||
await services.fileManager.deleteFile("events", event.id, fileId);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle file uploads
|
||||
if (fileChanges.added.size > 0) {
|
||||
for (const [filename, file] of fileChanges.added.entries()) {
|
||||
await uploadQueue.add(async () => {
|
||||
const uploadedFile = await services.fileManager.uploadFile(
|
||||
"events",
|
||||
event.id,
|
||||
filename,
|
||||
file
|
||||
);
|
||||
if (uploadedFile) {
|
||||
fileChanges.unchanged.push(uploadedFile);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update the event with the new file list
|
||||
updatedEvent.files = fileChanges.unchanged;
|
||||
|
||||
// Save the event
|
||||
let savedEvent;
|
||||
if (event.id) {
|
||||
// Update existing event
|
||||
console.log('Updating event:', event.id);
|
||||
await services.update.updateFields("events", event.id, eventData);
|
||||
savedEvent = await services.update.updateFields<Event>(
|
||||
Collections.EVENTS,
|
||||
event.id,
|
||||
updatedEvent
|
||||
);
|
||||
|
||||
// Handle file deletions first
|
||||
if (filesToDelete.size > 0) {
|
||||
console.log('Deleting files:', Array.from(filesToDelete));
|
||||
// Get current files
|
||||
const currentRecord = await pb.collection("events").getOne(event.id);
|
||||
let remainingFiles = [...currentRecord.files];
|
||||
// Clear cache to ensure fresh data
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.clearCache();
|
||||
|
||||
// Remove files marked for deletion
|
||||
for (const filename of filesToDelete) {
|
||||
const fileIndex = remainingFiles.indexOf(filename);
|
||||
if (fileIndex > -1) {
|
||||
remainingFiles.splice(fileIndex, 1);
|
||||
}
|
||||
}
|
||||
// Log success
|
||||
await services.sendLog.send(
|
||||
"success",
|
||||
"event_update",
|
||||
`Successfully updated event: ${savedEvent.event_name}`
|
||||
);
|
||||
|
||||
// Update record with remaining files
|
||||
await pb.collection("events").update(event.id, {
|
||||
files: remainingFiles
|
||||
});
|
||||
|
||||
// Sync the events collection to update IndexedDB
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.syncCollection(Collections.EVENTS);
|
||||
}
|
||||
|
||||
// Handle file additions
|
||||
if (selectedFiles.size > 0) {
|
||||
try {
|
||||
// Convert Map to array of Files
|
||||
const filesToUpload = Array.from(selectedFiles.values());
|
||||
console.log('Uploading files:', filesToUpload.map(f => f.name));
|
||||
|
||||
// Use appendFiles to preserve existing files
|
||||
await services.fileManager.appendFiles("events", event.id, "files", filesToUpload);
|
||||
|
||||
// Sync the events collection to update IndexedDB
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.syncCollection(Collections.EVENTS);
|
||||
} catch (error: any) {
|
||||
if (error.status === 413) {
|
||||
throw new Error("Files are too large. Please try uploading smaller files or fewer files at once.");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Show success toast
|
||||
toast.success(`Event "${savedEvent.event_name}" updated successfully!`);
|
||||
} else {
|
||||
// Create new event
|
||||
console.log('Creating new event');
|
||||
const newEvent = await pb.collection("events").create(eventData);
|
||||
console.log('New event created:', newEvent);
|
||||
savedEvent = await services.update.create<Event>(
|
||||
Collections.EVENTS,
|
||||
updatedEvent
|
||||
);
|
||||
|
||||
// Sync the events collection to update IndexedDB
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.syncCollection(Collections.EVENTS);
|
||||
// Log success
|
||||
await services.sendLog.send(
|
||||
"success",
|
||||
"event_create",
|
||||
`Successfully created event: ${savedEvent.event_name}`
|
||||
);
|
||||
|
||||
// Upload files if any
|
||||
if (selectedFiles.size > 0) {
|
||||
try {
|
||||
const filesToUpload = Array.from(selectedFiles.values());
|
||||
console.log('Uploading files:', filesToUpload.map(f => f.name));
|
||||
|
||||
// Use uploadFiles for new event
|
||||
await services.fileManager.uploadFiles("events", newEvent.id, "files", filesToUpload);
|
||||
} catch (error: any) {
|
||||
if (error.status === 413) {
|
||||
throw new Error("Files are too large. Please try uploading smaller files or fewer files at once.");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Show success toast
|
||||
toast.success(`Event "${savedEvent.event_name}" created successfully!`);
|
||||
}
|
||||
|
||||
// Show success message
|
||||
if (submitButton) {
|
||||
submitButton.classList.remove("btn-disabled");
|
||||
submitButton.classList.add("btn-success");
|
||||
const successIcon = document.createElement('span');
|
||||
successIcon.innerHTML = '<i class="iconify" data-icon="heroicons:check" style="width: 20px; height: 20px;"></i>';
|
||||
submitButton.textContent = '';
|
||||
submitButton.appendChild(successIcon);
|
||||
}
|
||||
|
||||
// Reset all state
|
||||
setHasUnsavedChanges(false);
|
||||
setSelectedFiles(new Map());
|
||||
setFilesToDelete(new Set());
|
||||
// Reset form state
|
||||
setEvent({
|
||||
id: '',
|
||||
event_name: '',
|
||||
event_description: '',
|
||||
event_code: '',
|
||||
location: '',
|
||||
id: "",
|
||||
created: "",
|
||||
updated: "",
|
||||
event_name: "",
|
||||
event_description: "",
|
||||
event_code: "",
|
||||
location: "",
|
||||
files: [],
|
||||
points_to_reward: 0,
|
||||
start_date: Get.formatLocalDate(new Date(), false),
|
||||
end_date: Get.formatLocalDate(new Date(), false),
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
published: false,
|
||||
has_food: false,
|
||||
attendees: []
|
||||
has_food: false
|
||||
});
|
||||
setSelectedFiles(new Map());
|
||||
setFilesToDelete(new Set());
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// Reset cache timestamp to force refresh
|
||||
if (window.lastCacheUpdate) {
|
||||
window.lastCacheUpdate = 0;
|
||||
}
|
||||
|
||||
// Trigger the callback
|
||||
onEventSaved?.();
|
||||
|
||||
// Close modal directly instead of using handleModalClose
|
||||
// Close modal
|
||||
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
|
||||
if (modal) modal.close();
|
||||
|
||||
// Force refresh of events list
|
||||
if (typeof window.fetchEvents === 'function') {
|
||||
// Refresh events list
|
||||
if (window.fetchEvents) {
|
||||
window.fetchEvents();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to save event:", error);
|
||||
if (submitButton) {
|
||||
submitButton.classList.remove("btn-disabled");
|
||||
submitButton.classList.add("btn-error");
|
||||
const errorIcon = document.createElement('span');
|
||||
errorIcon.innerHTML = '<i class="iconify" data-icon="heroicons:x-circle" style="width: 20px; height: 20px;"></i>';
|
||||
submitButton.textContent = '';
|
||||
submitButton.appendChild(errorIcon);
|
||||
|
||||
// Trigger callback
|
||||
if (onEventSaved) {
|
||||
onEventSaved();
|
||||
}
|
||||
alert(error.message || "Failed to save event. Please try again.");
|
||||
} catch (error) {
|
||||
console.error("Failed to save event:", error);
|
||||
toast.error(`Failed to ${event.id ? "update" : "create"} event: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
if (submitButton) {
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove("btn-disabled", "btn-success", "btn-error");
|
||||
submitButton.textContent = 'Save Changes';
|
||||
}
|
||||
if (cancelButton) cancelButton.disabled = false;
|
||||
window.hideLoading?.();
|
||||
}
|
||||
}, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting]);
|
||||
|
|
|
@ -3,6 +3,8 @@ import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
|||
import { DataSyncService } from "../../../scripts/database/DataSyncService";
|
||||
import { Collections } from "../../../schemas/pocketbase/schema";
|
||||
import type { Event, Log, User } from "../../../schemas/pocketbase";
|
||||
import { Get } from "../../../scripts/pocketbase/Get";
|
||||
import type { EventAttendee } from "../../../schemas/pocketbase";
|
||||
|
||||
// Extended User interface with points property
|
||||
interface ExtendedUser extends User {
|
||||
|
@ -18,109 +20,68 @@ export function Stats() {
|
|||
const [memberSince, setMemberSince] = useState<string | null>(null);
|
||||
const [upcomingEvents, setUpcomingEvents] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<ExtendedUser | null>(null);
|
||||
const [pointsEarned, setPointsEarned] = useState(0);
|
||||
const [attendancePercentage, setAttendancePercentage] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const auth = Authentication.getInstance();
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
const userId = auth.getCurrentUser()?.id;
|
||||
const get = Get.getInstance();
|
||||
const currentUser = auth.getCurrentUser();
|
||||
|
||||
if (!userId) return;
|
||||
|
||||
// Get current date
|
||||
const now = new Date();
|
||||
|
||||
// Get current quarter dates for points calculation
|
||||
const month = now.getMonth(); // 0-11
|
||||
let quarterStart = new Date();
|
||||
|
||||
// Fall: Sept-Dec
|
||||
if (month >= 8 && month <= 11) {
|
||||
quarterStart = new Date(now.getFullYear(), 8, 1); // Sept 1
|
||||
}
|
||||
// Winter: Jan-Mar
|
||||
else if (month >= 0 && month <= 2) {
|
||||
quarterStart = new Date(now.getFullYear(), 0, 1); // Jan 1
|
||||
}
|
||||
// Spring: Apr-Jun
|
||||
else if (month >= 3 && month <= 5) {
|
||||
quarterStart = new Date(now.getFullYear(), 3, 1); // Apr 1
|
||||
}
|
||||
// Summer: Jul-Aug
|
||||
else {
|
||||
quarterStart = new Date(now.getFullYear(), 6, 1); // Jul 1
|
||||
if (!currentUser) {
|
||||
setError("User not logged in");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync user data to ensure we have the latest
|
||||
await dataSync.syncCollection(Collections.USERS, `id = "${userId}"`);
|
||||
const userId = currentUser.id;
|
||||
|
||||
// Get user from IndexedDB
|
||||
const user = await dataSync.getItem<ExtendedUser>(Collections.USERS, userId);
|
||||
const totalPoints = user?.points || 0;
|
||||
|
||||
// Set membership status and date
|
||||
setMembershipStatus(user?.member_type || "Member");
|
||||
if (user?.created) {
|
||||
const createdDate = new Date(user.created);
|
||||
setMemberSince(createdDate.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long'
|
||||
}));
|
||||
// Get user data
|
||||
const userData = await get.getOne<ExtendedUser>("users", userId);
|
||||
if (!userData) {
|
||||
setError("Failed to load user data");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync logs for the current quarter to calculate points change
|
||||
await dataSync.syncCollection(
|
||||
Collections.LOGS,
|
||||
`user_id = "${userId}" && created >= "${quarterStart.toISOString()}" && type = "update" && part = "event check-in"`,
|
||||
"-created"
|
||||
// Set user data
|
||||
setUser(userData);
|
||||
|
||||
// Get events attended by the user
|
||||
const attendedEvents = await get.getList<EventAttendee>(
|
||||
"event_attendees",
|
||||
1,
|
||||
1000,
|
||||
`user="${userId}"`
|
||||
);
|
||||
|
||||
// Get logs from IndexedDB
|
||||
const logs = await dataSync.getData<Log>(
|
||||
Collections.LOGS,
|
||||
false, // Don't force sync again
|
||||
`user_id = "${userId}" && created >= "${quarterStart.toISOString()}" && type = "update" && part = "event check-in"`,
|
||||
"-created"
|
||||
);
|
||||
setEventsAttended(attendedEvents.totalItems);
|
||||
|
||||
// Calculate quarterly points
|
||||
const quarterlyPoints = logs.reduce((total, log) => {
|
||||
const pointsMatch = log.message?.match(/Awarded (\d+) points/);
|
||||
if (pointsMatch) {
|
||||
return total + parseInt(pointsMatch[1]);
|
||||
}
|
||||
return total;
|
||||
}, 0);
|
||||
|
||||
// Set points change message
|
||||
setPointsChange(quarterlyPoints > 0 ? `+${quarterlyPoints} this quarter` : "No activity");
|
||||
|
||||
// Sync events collection
|
||||
await dataSync.syncCollection(Collections.EVENTS);
|
||||
|
||||
// Get events from IndexedDB
|
||||
const events = await dataSync.getData<Event>(Collections.EVENTS);
|
||||
|
||||
// Count attended events
|
||||
const attendedEvents = events.filter(event =>
|
||||
event.attendees?.some(attendee => attendee.user_id === userId)
|
||||
);
|
||||
setEventsAttended(attendedEvents.length);
|
||||
|
||||
// Count upcoming events (events that haven't ended yet)
|
||||
const upcoming = events.filter(event => {
|
||||
if (!event.end_date) return false;
|
||||
const endDate = new Date(event.end_date);
|
||||
return endDate > now && event.published;
|
||||
// Calculate total points earned
|
||||
let totalPoints = 0;
|
||||
attendedEvents.items.forEach(attendee => {
|
||||
totalPoints += attendee.points_earned || 0;
|
||||
});
|
||||
setUpcomingEvents(upcoming.length);
|
||||
|
||||
// Set loyalty points
|
||||
setLoyaltyPoints(totalPoints);
|
||||
setPointsEarned(totalPoints);
|
||||
|
||||
// Get all events to calculate percentage
|
||||
const allEvents = await get.getList<Event>("events", 1, 1000);
|
||||
if (allEvents.totalItems > 0) {
|
||||
const percentage = (attendedEvents.totalItems / allEvents.totalItems) * 100;
|
||||
setAttendancePercentage(Math.round(percentage));
|
||||
} else {
|
||||
setAttendancePercentage(0);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fetching stats:", error);
|
||||
setError("Failed to load stats");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ import UserProfileSettings from "./SettingsSection/UserProfileSettings";
|
|||
import AccountSecuritySettings from "./SettingsSection/AccountSecuritySettings";
|
||||
import NotificationSettings from "./SettingsSection/NotificationSettings";
|
||||
import DisplaySettings from "./SettingsSection/DisplaySettings";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
---
|
||||
|
||||
<div id="settings-section" class="">
|
||||
|
@ -13,8 +12,6 @@ import { Toaster } from "react-hot-toast";
|
|||
<p class="opacity-70">Manage your account settings and preferences</p>
|
||||
</div>
|
||||
|
||||
<Toaster position="top-right" client:load />
|
||||
|
||||
<!-- Profile Settings Card -->
|
||||
<div
|
||||
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
|
||||
|
|
Loading…
Reference in a new issue