event attendees changes

still broken
This commit is contained in:
chark1es 2025-03-02 18:11:46 -08:00
parent 9481f433de
commit 36e1f4663b
7 changed files with 2819 additions and 2400 deletions

View file

@ -7,7 +7,7 @@ import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { Collections } from "../../../schemas/pocketbase/schema"; import { Collections } from "../../../schemas/pocketbase/schema";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import toast from "react-hot-toast"; 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 // Extended Event interface with additional properties needed for this component
interface ExtendedEvent extends Event { interface ExtendedEvent extends Event {
@ -51,91 +51,152 @@ const EventCheckIn = () => {
// Log the check-in attempt // Log the check-in attempt
await logger.send( await logger.send(
"attempt", "info",
"event_check_in", "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, // Validate event code
// directly query PocketBase for the event with the given code if (!eventCode || eventCode.trim() === "") {
// 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) {
await logger.send( await logger.send(
"error", "error",
"event_check_in", "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 // Get event by code
const attendees = event.attendees || []; const events = await get.getList<Event>(
if (attendees.some((entry) => entry.user_id === currentUser.id)) { Collections.EVENTS,
1,
1,
`event_code="${eventCode}"`
);
if (events.totalItems === 0) {
await logger.send( await logger.send(
"error", "error",
"event_check_in", "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) // Check if the event is active (has started and hasn't ended yet)
const currentTime = new Date(); const currentTime = new Date();
const eventStartDate = new Date(event.start_date); // Now properly converted to local time by Get const eventStartDate = new Date(event.start_date);
const eventEndDate = new Date(event.end_date); // Now properly converted to local time by Get const eventEndDate = new Date(event.end_date);
if (eventStartDate > currentTime) { if (currentTime < eventStartDate) {
await logger.send( await logger.send(
"error", "error",
"event_check_in", "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( await logger.send(
"error", "error",
"event_check_in", "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( await logger.send(
"info", "info",
"event_check_in", "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, show food selection modal
if (event.has_food) { 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; const modal = document.getElementById("foodSelectionModal") as HTMLDialogElement;
modal.showModal(); if (modal) modal.showModal();
} else { } else {
// If no food, complete check-in directly // If no food, show confirmation modal
await completeCheckIn(event, null); const modal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
if (modal) modal.showModal();
} }
} catch (error: any) { } 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> { async function completeCheckIn(event: Event, foodSelection: string | null): Promise<void> {
try { try {
setIsLoading(true);
const auth = Authentication.getInstance(); const auth = Authentication.getInstance();
const update = Update.getInstance(); const update = Update.getInstance();
const logger = SendLog.getInstance(); const logger = SendLog.getInstance();
@ -146,119 +207,108 @@ const EventCheckIn = () => {
throw new Error("You must be logged in to check in to events"); throw new Error("You must be logged in to check in to events");
} }
// Check if user is already checked in const userId = currentUser.id;
const userId = auth.getUserId(); const eventId = event.id;
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 || [];
// Check if user is already checked in // Check if user is already checked in
const isAlreadyCheckedIn = attendees.some( const get = Get.getInstance();
(attendee) => attendee.user_id === userId const existingAttendees = await get.getList<EventAttendee>(
Collections.EVENT_ATTENDEES,
1,
1,
`user="${userId}" && event="${eventId}"`
); );
const isAlreadyCheckedIn = existingAttendees.totalItems > 0;
if (isAlreadyCheckedIn) { 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"); throw new Error("You have already checked in to this event");
} }
// Add new attendee entry to the array // Create new attendee record
const updatedAttendees = [...existingAttendees, attendeeEntry]; 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 // Create the attendee record using PocketBase's create method
await update.updateField("events", event.id, "attendees", updatedAttendees); // 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, console.log("Successfully created attendance record");
// only sync the user's collection to update their points } catch (createError: any) {
if (event.points_to_reward > 0) { console.error("Error creating attendance record:", createError);
await dataSync.syncCollection(Collections.USERS);
// 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 // Log successful check-in
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
await logger.send( await logger.send(
"check_in", "info",
"events", "event_check_in",
`Checked in to event ${event.event_name}` `Successfully checked in to event: ${event.event_name}`
); );
// Close the food selection modal if it's open // Clear event code from local storage
const modal = document.getElementById("foodSelectionModal") as HTMLDialogElement; await dataSync.clearEventCode();
if (modal) {
modal.close(); // Show success message with event name and points
setFoodInput(""); 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) { } 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (currentCheckInEvent) { if (!currentCheckInEvent) return;
await completeCheckIn(currentCheckInEvent, foodInput.trim());
setCurrentCheckInEvent(null); 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 */} {/* Food Selection Modal */}
<dialog id="foodSelectionModal" className="modal"> <dialog id="foodSelectionModal" className="modal">
<div className="modal-box"> <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> <p className="mb-4">This event has food! Please let us know what you'd like to eat:</p>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="form-control"> <div className="form-control">
@ -345,7 +401,17 @@ const EventCheckIn = () => {
modal.close(); modal.close();
setCurrentCheckInEvent(null); setCurrentCheckInEvent(null);
}}>Cancel</button> }}>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> </div>
</form> </form>
</div> </div>
@ -353,6 +419,58 @@ const EventCheckIn = () => {
<button>close</button> <button>close</button>
</form> </form>
</dialog> </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>
</> </>
); );
}; };

View file

@ -5,7 +5,7 @@ import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { DataSyncService } from "../../../scripts/database/DataSyncService"; import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { DexieService } from "../../../scripts/database/DexieService"; import { DexieService } from "../../../scripts/database/DexieService";
import { Collections } from "../../../schemas/pocketbase/schema"; 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 // Extended Event interface with additional properties needed for this component
interface ExtendedEvent extends Event { interface ExtendedEvent extends Event {
@ -132,76 +132,111 @@ const EventLoad = () => {
); );
const renderEventCard = (event: Event) => { const renderEventCard = (event: Event) => {
const startDate = new Date(event.start_date); try {
const endDate = new Date(event.end_date); // Get authentication instance
const now = new Date(); const auth = Authentication.getInstance();
const isPastEvent = endDate < now; const currentUser = auth.getCurrentUser();
// Get current user to check attendance // Check if user has attended this event by querying the event_attendees collection
const auth = Authentication.getInstance(); let hasAttended = false;
const currentUser = auth.getCurrentUser(); if (currentUser) {
const hasAttended = currentUser && event.attendees?.some(entry => entry.user_id === currentUser.id); // 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 hasAttendedEvent = attendees.totalItems > 0;
const eventDataId = `event_${event.id}`;
window[eventDataId] = event;
return ( // Update the card UI based on attendance status
<div key={event.id} className="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-300 relative overflow-hidden"> const cardElement = document.getElementById(`event-card-${event.id}`);
<div className="card-body p-3 sm:p-4"> if (cardElement && hasAttendedEvent) {
<div className="flex flex-col h-full"> const attendedBadge = cardElement.querySelector('.attended-badge');
<div className="flex flex-col gap-2"> if (attendedBadge) {
<div className="flex-1"> (attendedBadge as HTMLElement).style.display = 'flex';
<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> } catch (error) {
<div className="text-xs sm:text-sm opacity-75"> console.error("Error checking attendance status:", error);
{startDate.toLocaleDateString("en-US", { }
weekday: "short", }, 0);
month: "short", }
day: "numeric",
})} // Store event data in window object with unique ID
{" • "} const eventDataId = `event_${event.id}`;
{startDate.toLocaleTimeString("en-US", { window[eventDataId] = event;
hour: "numeric",
minute: "2-digit", 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> </div>
</div>
<div className="text-xs sm:text-sm text-base-content/70 my-2 line-clamp-2"> <div className="text-xs sm:text-sm text-base-content/70 my-2 line-clamp-2">
{event.event_description || "No description available"} {event.event_description || "No description available"}
</div> </div>
<div className="flex flex-wrap items-center gap-2 mt-auto pt-2"> <div className="flex flex-wrap items-center gap-2 mt-auto pt-2">
{event.files && event.files.length > 0 && ( {event.files && event.files.length > 0 && (
<button <button
onClick={() => window.openDetailsModal(event as ExtendedEvent)} 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" 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" /> <Icon icon="heroicons:document-duplicate" className="h-3 w-3 sm:h-4 sm:w-4" />
Files ({event.files.length}) Files ({event.files.length})
</button> </button>
)} )}
{isPastEvent && ( {isPastEvent && (
<div className={`badge ${hasAttended ? 'badge-success' : 'badge-ghost'} text-xs gap-1`}> <div className={`badge ${hasAttended ? 'badge-success' : 'badge-ghost'} text-xs gap-1`}>
<Icon <Icon
icon={hasAttended ? "heroicons:check-circle" : "heroicons:x-circle"} icon={hasAttended ? "heroicons:check-circle" : "heroicons:x-circle"}
className="h-3 w-3" className="h-3 w-3"
/> />
{hasAttended ? 'Attended' : 'Not Attended'} {hasAttended ? 'Attended' : 'Not Attended'}
</div>
)}
<div className="text-xs sm:text-sm opacity-75 ml-auto">
{event.location}
</div> </div>
)}
<div className="text-xs sm:text-sm opacity-75 ml-auto">
{event.location}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> );
); } catch (error) {
console.error("Error rendering event card:", error);
return null;
}
}; };
const loadEvents = async () => { const loadEvents = async () => {

File diff suppressed because it is too large Load diff

View file

@ -5,13 +5,22 @@ import { SendLog } from '../../../scripts/pocketbase/SendLog';
import { DataSyncService } from '../../../scripts/database/DataSyncService'; import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema'; import { Collections } from '../../../schemas/pocketbase/schema';
import { Icon } from "@iconify/react"; 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 // Extended User interface with additional properties needed for this component
interface User extends SchemaUser { interface User extends SchemaUser {
member_type: string; 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 // Cache for storing user data
const userCache = new Map<string, { const userCache = new Map<string, {
data: User; data: User;
@ -58,7 +67,6 @@ const HighlightText = ({ text, searchTerms }: { text: string | number | null | u
interface EventFields { interface EventFields {
id: true; id: true;
event_name: true; event_name: true;
attendees: true;
} }
interface UserFields { interface UserFields {
@ -73,7 +81,7 @@ interface UserFields {
} }
// Constants for field selection // 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']; const USER_FIELDS: (keyof UserFields)[] = ['id', 'name', 'email', 'pid', 'member_id', 'member_type', 'graduation_year', 'major'];
export default function Attendees() { export default function Attendees() {
@ -86,6 +94,7 @@ export default function Attendees() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [processedSearchTerms, setProcessedSearchTerms] = useState<string[]>([]); const [processedSearchTerms, setProcessedSearchTerms] = useState<string[]>([]);
const [refreshKey, setRefreshKey] = useState(0); // Add a refresh key to force re-fetching
const get = Get.getInstance(); const get = Get.getInstance();
const auth = Authentication.getInstance(); const auth = Authentication.getInstance();
@ -141,6 +150,8 @@ export default function Attendees() {
// Optimized user data fetching with cache // Optimized user data fetching with cache
const fetchUserData = useCallback(async (userIds: string[]) => { const fetchUserData = useCallback(async (userIds: string[]) => {
if (!userIds.length) return new Map<string, User>();
const now = Date.now(); const now = Date.now();
const uncachedIds: string[] = []; const uncachedIds: string[] = [];
const cachedUsers = new Map<string, User>(); const cachedUsers = new Map<string, User>();
@ -160,48 +171,86 @@ export default function Attendees() {
return cachedUsers; return cachedUsers;
} }
// Fetch uncached users
try { 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 // Fetch all uncached users in one request
if (uncachedIds.length > 0) { const usersResponse = await get.getAll<User>(
const idFilter = uncachedIds.map(id => `id = "${id}"`).join(' || '); Collections.USERS,
await dataSync.syncCollection(Collections.USERS, idFilter); userFilter
}
// 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;
}
})
); );
// Update cache and merge with cached users // Process the fetched users
users.forEach(user => { usersResponse.forEach(user => {
if (user) { // Add member_type if it doesn't exist
userCache.set(user.id, { data: user, timestamp: now }); const userWithMemberType = {
cachedUsers.set(user.id, user); ...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) { } 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; return cachedUsers;
}, []); }, []);
// Function to refresh attendees data
const refreshAttendees = useCallback(() => {
setRefreshKey(prev => prev + 1);
}, []);
// Listen for the custom event // Listen for the custom event
useEffect(() => { useEffect(() => {
const handleUpdateAttendees = async (e: CustomEvent<{ eventId: string; eventName: string }>) => { const handleUpdateAttendees = async (e: CustomEvent<{ eventId: string; eventName: string }>) => {
setEventId(e.detail.eventId); setEventId(e.detail.eventId);
setEventName(e.detail.eventName); setEventName(e.detail.eventName);
setCurrentPage(1); // Reset pagination on new event setCurrentPage(1); // Reset pagination on new event
setSearchTerm(''); // Clear search on new event
// Log the attendees view action // Log the attendees view action
try { try {
@ -217,17 +266,22 @@ export default function Attendees() {
}; };
window.addEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener); window.addEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener);
// Expose refresh function to window
(window as any).refreshAttendees = refreshAttendees;
return () => { return () => {
window.removeEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener); window.removeEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener);
delete (window as any).refreshAttendees;
}; };
}, []); }, [refreshAttendees]);
// Update search terms when search input changes // Update search terms when search input changes
useEffect(() => { useEffect(() => {
updateProcessedSearchTerms(searchTerm); updateProcessedSearchTerms(searchTerm);
}, [searchTerm, updateProcessedSearchTerms]); }, [searchTerm, updateProcessedSearchTerms]);
// Fetch event data when eventId changes // Fetch event data when eventId changes or refreshKey changes
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
const fetchEventData = async () => { const fetchEventData = async () => {
@ -243,44 +297,118 @@ export default function Attendees() {
setLoading(true); setLoading(true);
setError(null); setError(null);
if (!eventId) {
setAttendeesList([]);
setUsers(new Map());
return;
}
// Clear cache to ensure fresh data
const dataSync = DataSyncService.getInstance(); const dataSync = DataSyncService.getInstance();
await dataSync.clearCache();
await dataSync.syncCollection(Collections.EVENTS, `id="${eventId}"`);
// Sync the event data const event = await get.getOne<Event>(Collections.EVENTS, eventId);
await dataSync.syncCollection(Collections.EVENTS, `id = "${eventId}"`);
// Get the event from IndexedDB
const event = await dataSync.getItem<Event>(Collections.EVENTS, eventId);
if (!isMounted) return;
if (!event) { if (!event) {
setError('Event not found'); setError("Event not found");
setAttendeesList([]); setAttendeesList([]);
setUsers(new Map()); setUsers(new Map());
return; return;
} }
if (!event.attendees?.length) { // Fetch attendees from event_attendees collection with a higher limit
setAttendeesList([]); const attendeesList = await get.getList<EventAttendee>(
setUsers(new Map()); Collections.EVENT_ATTENDEES,
1,
2000, // Increased limit to handle more attendees
`event="${eventId}"`
);
if (!attendeesList.items.length) {
if (isMounted) {
setAttendeesList([]);
setUsers(new Map());
}
return; return;
} }
setAttendeesList(event.attendees); // Transform EventAttendee records to match the expected format
const transformedAttendees = attendeesList.items.map(attendee => ({
// Fetch user details with cache user_id: attendee.user, // This is the user ID (relation)
const userIds = [...new Set(event.attendees.map(a => a.user_id))]; time_checked_in: attendee.time_checked_in,
const userMap = await fetchUserData(userIds); food: attendee.food_ate,
points_earned: attendee.points_earned
}));
if (isMounted) { 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) { } catch (error) {
if (isMounted) { console.error("Error fetching event data:", error);
console.error('Failed to fetch event data:', error); setError("Failed to load event data. Please try refreshing.");
setError('Failed to load event data');
setAttendeesList([]);
}
} finally { } finally {
if (isMounted) { if (isMounted) {
setLoading(false); setLoading(false);
@ -290,15 +418,18 @@ export default function Attendees() {
fetchEventData(); fetchEventData();
return () => { isMounted = false; }; return () => { isMounted = false; };
}, [eventId, auth, fetchUserData]); }, [eventId, auth, fetchUserData, refreshKey]);
// Reset state when modal is closed // Reset state when modal is closed
useEffect(() => { useEffect(() => {
const handleModalClose = () => { const handleModalClose = () => {
setEventId(''); setEventId('');
setEventName('');
setAttendeesList([]); setAttendeesList([]);
setUsers(new Map()); setUsers(new Map());
setError(null); setError(null);
setSearchTerm('');
setCurrentPage(1);
}; };
const modal = document.getElementById('attendeesModal'); const modal = document.getElementById('attendeesModal');
@ -332,15 +463,22 @@ export default function Attendees() {
'Graduation Year', 'Graduation Year',
'Major', 'Major',
'Check-in Time', 'Check-in Time',
'Food Choice' 'Food Choice',
'Points Earned'
].map(escapeCSV); ].map(escapeCSV);
// Create CSV rows // Create CSV rows
const rows = attendeesList.map(attendee => { const rows = attendeesList.map(attendee => {
const user = users.get(attendee.user_id); 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 [ return [
user?.name || 'Unknown User', user?.name || `User ${attendee.user_id}`,
user?.email || 'N/A', user?.email || 'N/A',
user?.pid || 'N/A', user?.pid || 'N/A',
user?.member_id || 'N/A', user?.member_id || 'N/A',
@ -348,7 +486,8 @@ export default function Attendees() {
user?.graduation_year || 'N/A', user?.graduation_year || 'N/A',
user?.major || 'N/A', user?.major || 'N/A',
checkInTime, checkInTime,
attendee.food || 'N/A' attendee.food || 'N/A',
attendee.points_earned || 'N/A'
].map(escapeCSV); ].map(escapeCSV);
}); });
@ -375,6 +514,8 @@ export default function Attendees() {
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); // Clean up the URL object URL.revokeObjectURL(url); // Clean up the URL object
toast.success(`Downloaded ${rows.length} attendee records`);
}; };
if (loading) { if (loading) {
@ -390,6 +531,13 @@ export default function Attendees() {
<div className="alert alert-error"> <div className="alert alert-error">
<Icon icon="heroicons:exclamation-circle" className="h-6 w-6" /> <Icon icon="heroicons:exclamation-circle" className="h-6 w-6" />
<span>{error}</span> <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> </div>
); );
} }
@ -403,6 +551,13 @@ export default function Attendees() {
<div className="text-center py-8 text-base-content/70"> <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" /> <Icon icon="heroicons:user-group" className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No attendees yet</p> <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> </div>
); );
} }
@ -426,13 +581,22 @@ export default function Attendees() {
/> />
</div> </div>
</div> </div>
<button <div className="flex gap-2">
className="btn btn-primary btn-sm gap-2" <button
onClick={downloadAttendeesCSV} className="btn btn-outline btn-sm gap-2"
> onClick={refreshAttendees}
<Icon icon="heroicons:arrow-down-tray" className="h-4 w-4" /> >
Download CSV <Icon icon="heroicons:arrow-path" className="h-4 w-4" />
</button> 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> </div>
{/* Stats Row */} {/* Stats Row */}
@ -462,16 +626,22 @@ export default function Attendees() {
<th className="bg-base-100">Major</th> <th className="bg-base-100">Major</th>
<th className="bg-base-100">Check-in Time</th> <th className="bg-base-100">Check-in Time</th>
<th className="bg-base-100">Food Choice</th> <th className="bg-base-100">Food Choice</th>
<th className="bg-base-100">Points</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{paginatedAttendees.map((attendee, index) => { {paginatedAttendees.map((attendee, index) => {
const user = users.get(attendee.user_id); 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 ( return (
<tr key={`${attendee.user_id}-${index}`}> <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?.email || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={user?.pid || '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> <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={user?.major || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={checkInTime} searchTerms={processedSearchTerms} /></td> <td><HighlightText text={checkInTime} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={attendee.food || 'N/A'} 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> </tr>
); );
})} })}

View file

@ -9,6 +9,7 @@ import FilePreview from "../universal/FilePreview";
import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase"; import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase";
import { DataSyncService } from '../../../scripts/database/DataSyncService'; import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema'; 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. // 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 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) { export default function EventEditor({ onEventSaved }: EventEditorProps) {
// State for form data and UI // State for form data and UI
const [event, setEvent] = useState<Event>({ const [event, setEvent] = useState<Event>({
id: '', id: "",
event_name: '', created: "",
event_description: '', updated: "",
event_code: '', event_name: "",
location: '', event_description: "",
event_code: "",
location: "",
files: [], files: [],
points_to_reward: 0, points_to_reward: 0,
start_date: Get.formatLocalDate(new Date(), false), start_date: "",
end_date: Get.formatLocalDate(new Date(), false), end_date: "",
published: false, published: false,
has_food: false, has_food: false
attendees: []
}); });
const [previewUrl, setPreviewUrl] = useState(""); const [previewUrl, setPreviewUrl] = useState("");
@ -557,7 +559,16 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
const initializeEventData = useCallback(async (eventId: string) => { const initializeEventData = useCallback(async (eventId: string) => {
try { try {
if (eventId) { 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 // Ensure dates are properly formatted for datetime-local input
if (eventData.start_date) { if (eventData.start_date) {
@ -573,20 +584,22 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
} }
setEvent(eventData); setEvent(eventData);
console.log("Event data loaded successfully:", eventData);
} else { } else {
setEvent({ setEvent({
id: '', id: '',
created: '',
updated: '',
event_name: '', event_name: '',
event_description: '', event_description: '',
event_code: '', event_code: '',
location: '', location: '',
files: [], files: [],
points_to_reward: 0, points_to_reward: 0,
start_date: Get.formatLocalDate(new Date(), false), start_date: '',
end_date: Get.formatLocalDate(new Date(), false), end_date: '',
published: false, published: false,
has_food: false, has_food: false
attendees: []
}); });
} }
setSelectedFiles(new Map()); setSelectedFiles(new Map());
@ -595,7 +608,7 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
} catch (error) { } catch (error) {
console.error("Failed to initialize event data:", 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]); }, [services.get]);
@ -614,7 +627,7 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
modal.showModal(); modal.showModal();
} catch (error) { } catch (error) {
console.error("Failed to open edit modal:", 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({ setEvent({
id: '', id: "",
event_name: '', created: "",
event_description: '', updated: "",
event_code: '', event_name: "",
location: '', event_description: "",
event_code: "",
location: "",
files: [], files: [],
points_to_reward: 0, points_to_reward: 0,
start_date: Get.formatLocalDate(new Date(), false), start_date: "",
end_date: Get.formatLocalDate(new Date(), false), end_date: "",
published: false, published: false,
has_food: false, has_food: false
attendees: []
}); });
setSelectedFiles(new Map()); setSelectedFiles(new Map());
setFilesToDelete(new Set()); setFilesToDelete(new Set());
setShowPreview(false); setShowPreview(false);
setHasUnsavedChanges(false); setPreviewUrl("");
setPreviewFilename("");
const modal = document.getElementById("editEventModal") as HTMLDialogElement; const modal = document.getElementById("editEventModal") as HTMLDialogElement;
if (modal) modal.close(); if (modal) modal.close();
@ -663,176 +678,160 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
e.preventDefault(); e.preventDefault();
if (isSubmitting) return; 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 { try {
setIsSubmitting(true);
window.showLoading?.(); window.showLoading?.();
const pb = services.auth.getPocketBase();
console.log('Form submission started'); const submitButton = document.getElementById("submitEventButton") as HTMLButtonElement;
console.log('Event data:', event); const cancelButton = document.getElementById("cancelEventButton") as HTMLButtonElement;
const formData = new FormData(form); if (submitButton) {
const eventData = { submitButton.disabled = true;
event_name: formData.get("editEventName"), submitButton.classList.add("btn-disabled");
event_code: formData.get("editEventCode"), }
event_description: formData.get("editEventDescription"), if (cancelButton) cancelButton.disabled = true;
location: formData.get("editEventLocation"),
points_to_reward: Number(formData.get("editEventPoints")), // 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, start_date: formData.get("editEventStartDate") as string,
end_date: formData.get("editEventEndDate") as string, end_date: formData.get("editEventEndDate") as string,
published: formData.get("editEventPublished") === "on", published: formData.get("editEventPublished") === "on",
has_food: formData.get("editEventHasFood") === "on", has_food: formData.get("editEventHasFood") === "on"
attendees: event.attendees || []
}; };
// 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) { if (event.id) {
// Update existing event // Update existing event
console.log('Updating event:', event.id); savedEvent = await services.update.updateFields<Event>(
await services.update.updateFields("events", event.id, eventData); Collections.EVENTS,
event.id,
updatedEvent
);
// Handle file deletions first // Clear cache to ensure fresh data
if (filesToDelete.size > 0) { const dataSync = DataSyncService.getInstance();
console.log('Deleting files:', Array.from(filesToDelete)); await dataSync.clearCache();
// Get current files
const currentRecord = await pb.collection("events").getOne(event.id);
let remainingFiles = [...currentRecord.files];
// Remove files marked for deletion // Log success
for (const filename of filesToDelete) { await services.sendLog.send(
const fileIndex = remainingFiles.indexOf(filename); "success",
if (fileIndex > -1) { "event_update",
remainingFiles.splice(fileIndex, 1); `Successfully updated event: ${savedEvent.event_name}`
} );
}
// Update record with remaining files // Show success toast
await pb.collection("events").update(event.id, { toast.success(`Event "${savedEvent.event_name}" updated successfully!`);
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;
}
}
} else { } else {
// Create new event // Create new event
console.log('Creating new event'); savedEvent = await services.update.create<Event>(
const newEvent = await pb.collection("events").create(eventData); Collections.EVENTS,
console.log('New event created:', newEvent); updatedEvent
);
// Sync the events collection to update IndexedDB // Log success
const dataSync = DataSyncService.getInstance(); await services.sendLog.send(
await dataSync.syncCollection(Collections.EVENTS); "success",
"event_create",
`Successfully created event: ${savedEvent.event_name}`
);
// Upload files if any // Show success toast
if (selectedFiles.size > 0) { toast.success(`Event "${savedEvent.event_name}" created successfully!`);
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 message // Reset form state
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());
setEvent({ setEvent({
id: '', id: "",
event_name: '', created: "",
event_description: '', updated: "",
event_code: '', event_name: "",
location: '', event_description: "",
event_code: "",
location: "",
files: [], files: [],
points_to_reward: 0, points_to_reward: 0,
start_date: Get.formatLocalDate(new Date(), false), start_date: "",
end_date: Get.formatLocalDate(new Date(), false), end_date: "",
published: false, published: false,
has_food: false, has_food: false
attendees: []
}); });
setSelectedFiles(new Map());
setFilesToDelete(new Set());
setHasUnsavedChanges(false);
// Reset cache timestamp to force refresh // Close modal
if (window.lastCacheUpdate) {
window.lastCacheUpdate = 0;
}
// Trigger the callback
onEventSaved?.();
// Close modal directly instead of using handleModalClose
const modal = document.getElementById("editEventModal") as HTMLDialogElement; const modal = document.getElementById("editEventModal") as HTMLDialogElement;
if (modal) modal.close(); if (modal) modal.close();
// Force refresh of events list // Refresh events list
if (typeof window.fetchEvents === 'function') { if (window.fetchEvents) {
window.fetchEvents(); window.fetchEvents();
} }
} catch (error: any) {
console.error("Failed to save event:", error); // Trigger callback
if (submitButton) { if (onEventSaved) {
submitButton.classList.remove("btn-disabled"); onEventSaved();
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);
} }
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 { } finally {
setIsSubmitting(false); 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?.(); window.hideLoading?.();
} }
}, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting]); }, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting]);

View file

@ -3,6 +3,8 @@ import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { DataSyncService } from "../../../scripts/database/DataSyncService"; import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { Collections } from "../../../schemas/pocketbase/schema"; import { Collections } from "../../../schemas/pocketbase/schema";
import type { Event, Log, User } from "../../../schemas/pocketbase"; 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 // Extended User interface with points property
interface ExtendedUser extends User { interface ExtendedUser extends User {
@ -18,109 +20,68 @@ export function Stats() {
const [memberSince, setMemberSince] = useState<string | null>(null); const [memberSince, setMemberSince] = useState<string | null>(null);
const [upcomingEvents, setUpcomingEvents] = useState(0); const [upcomingEvents, setUpcomingEvents] = useState(0);
const [isLoading, setIsLoading] = useState(true); 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(() => { useEffect(() => {
const fetchStats = async () => { const fetchStats = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
setError(null);
const auth = Authentication.getInstance(); const auth = Authentication.getInstance();
const dataSync = DataSyncService.getInstance(); const get = Get.getInstance();
const userId = auth.getCurrentUser()?.id; const currentUser = auth.getCurrentUser();
if (!userId) return; if (!currentUser) {
setError("User not logged in");
// Get current date return;
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
} }
// Sync user data to ensure we have the latest const userId = currentUser.id;
await dataSync.syncCollection(Collections.USERS, `id = "${userId}"`);
// Get user from IndexedDB // Get user data
const user = await dataSync.getItem<ExtendedUser>(Collections.USERS, userId); const userData = await get.getOne<ExtendedUser>("users", userId);
const totalPoints = user?.points || 0; if (!userData) {
setError("Failed to load user data");
// Set membership status and date return;
setMembershipStatus(user?.member_type || "Member");
if (user?.created) {
const createdDate = new Date(user.created);
setMemberSince(createdDate.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long'
}));
} }
// Sync logs for the current quarter to calculate points change // Set user data
await dataSync.syncCollection( setUser(userData);
Collections.LOGS,
`user_id = "${userId}" && created >= "${quarterStart.toISOString()}" && type = "update" && part = "event check-in"`, // Get events attended by the user
"-created" const attendedEvents = await get.getList<EventAttendee>(
"event_attendees",
1,
1000,
`user="${userId}"`
); );
// Get logs from IndexedDB setEventsAttended(attendedEvents.totalItems);
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"
);
// Calculate quarterly points // Calculate total points earned
const quarterlyPoints = logs.reduce((total, log) => { let totalPoints = 0;
const pointsMatch = log.message?.match(/Awarded (\d+) points/); attendedEvents.items.forEach(attendee => {
if (pointsMatch) { totalPoints += attendee.points_earned || 0;
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;
}); });
setUpcomingEvents(upcoming.length);
// Set loyalty points setPointsEarned(totalPoints);
setLoyaltyPoints(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) { } catch (error) {
console.error("Error fetching stats:", error); console.error("Error fetching stats:", error);
setError("Failed to load stats");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View file

@ -4,7 +4,6 @@ import UserProfileSettings from "./SettingsSection/UserProfileSettings";
import AccountSecuritySettings from "./SettingsSection/AccountSecuritySettings"; import AccountSecuritySettings from "./SettingsSection/AccountSecuritySettings";
import NotificationSettings from "./SettingsSection/NotificationSettings"; import NotificationSettings from "./SettingsSection/NotificationSettings";
import DisplaySettings from "./SettingsSection/DisplaySettings"; import DisplaySettings from "./SettingsSection/DisplaySettings";
import { Toaster } from "react-hot-toast";
--- ---
<div id="settings-section" class=""> <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> <p class="opacity-70">Manage your account settings and preferences</p>
</div> </div>
<Toaster position="top-right" client:load />
<!-- Profile Settings Card --> <!-- Profile Settings Card -->
<div <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" 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"