initial sponsor page

This commit is contained in:
chark1es 2025-04-05 15:30:26 -07:00
parent 43c1fc074a
commit 12207546de
14 changed files with 2480 additions and 188 deletions

View file

@ -0,0 +1,187 @@
---
import ResumeList from "./ResumeDatabase/ResumeList";
import ResumeFilters from "./ResumeDatabase/ResumeFilters";
import ResumeSearch from "./ResumeDatabase/ResumeSearch";
import ResumeDetail from "./ResumeDatabase/ResumeDetail";
---
<div class="space-y-6">
<div
class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4"
>
<div>
<h2 class="text-2xl font-bold">Resume Database</h2>
<p class="text-base-content/70">
Search and filter student resumes for recruitment opportunities
</p>
</div>
<div class="flex items-center gap-2">
<button id="refreshResumesBtn" class="btn btn-sm btn-outline">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-refresh-cw"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
></path>
<path d="M21 3v5h-5"></path>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
></path>
<path d="M3 21v-5h5"></path>
</svg>
<span>Refresh</span>
</button>
</div>
</div>
<!-- Resume Database Interface -->
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<!-- Filters Panel -->
<div class="lg:col-span-1">
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h3 class="card-title text-lg">Filters</h3>
<ResumeFilters client:load />
</div>
</div>
</div>
<!-- Resume List and Detail View -->
<div class="lg:col-span-3 space-y-6">
<!-- Search Bar -->
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<ResumeSearch client:load />
</div>
</div>
<!-- Resume List -->
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h3 class="card-title text-lg">Student Resumes</h3>
<ResumeList client:load />
</div>
</div>
<!-- Resume Detail View (initially hidden, shown when a resume is selected) -->
<div
id="resumeDetailContainer"
class="card bg-base-100 shadow-md hidden"
>
<div class="card-body">
<div class="flex justify-between items-center">
<h3 class="card-title text-lg">Resume Details</h3>
<button
id="closeResumeDetail"
class="btn btn-sm btn-ghost"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-x"
>
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
<ResumeDetail client:load />
</div>
</div>
</div>
</div>
</div>
<script>
import { Authentication } from "../../scripts/pocketbase/Authentication";
import { Get } from "../../scripts/pocketbase/Get";
import { Realtime } from "../../scripts/pocketbase/Realtime";
import { Collections } from "../../schemas/pocketbase/schema";
// Initialize services
const auth = Authentication.getInstance();
const get = Get.getInstance();
const realtime = Realtime.getInstance();
// Initialize the resume database
async function initResumeDatabase() {
if (!auth.isAuthenticated()) {
console.error("User not authenticated");
return;
}
try {
// Set up event listeners
document
.getElementById("refreshResumesBtn")
?.addEventListener("click", () => {
// Dispatch custom event to notify components to refresh
window.dispatchEvent(
new CustomEvent("resumeDatabaseRefresh")
);
});
// Close resume detail view
document
.getElementById("closeResumeDetail")
?.addEventListener("click", () => {
document
.getElementById("resumeDetailContainer")
?.classList.add("hidden");
});
// Set up realtime updates
setupRealtimeUpdates();
} catch (error) {
console.error("Error initializing resume database:", error);
}
}
// Set up realtime updates
function setupRealtimeUpdates() {
// Subscribe to users collection for resume updates
realtime.subscribeToCollection(Collections.USERS, (data) => {
console.log("User data updated:", data);
// Dispatch custom event to notify components to refresh
window.dispatchEvent(new CustomEvent("resumeDatabaseRefresh"));
});
}
// Initialize when document is ready
document.addEventListener("DOMContentLoaded", initResumeDatabase);
// Custom event listener for resume selection
window.addEventListener("resumeSelected", (e) => {
const customEvent = e as CustomEvent;
const resumeId = customEvent.detail.resumeId;
if (resumeId) {
// Show the resume detail container
document
.getElementById("resumeDetailContainer")
?.classList.remove("hidden");
// Dispatch event to the ResumeDetail component
window.dispatchEvent(
new CustomEvent("loadResumeDetail", {
detail: { resumeId },
})
);
}
});
</script>

View file

@ -0,0 +1,181 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { User } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
interface ResumeUser {
id: string;
name: string;
email: string;
major?: string;
graduation_year?: number;
resume?: string;
avatar?: string;
}
export default function ResumeDetail() {
const [user, setUser] = useState<ResumeUser | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
// Listen for resume selection
const handleResumeSelection = (event: CustomEvent) => {
const { resumeId } = event.detail;
if (resumeId) {
loadResumeDetails(resumeId);
}
};
window.addEventListener('loadResumeDetail', handleResumeSelection as EventListener);
return () => {
window.removeEventListener('loadResumeDetail', handleResumeSelection as EventListener);
};
}, []);
const loadResumeDetails = async (userId: string) => {
try {
setLoading(true);
setError(null);
// Get user details
const user = await get.getOne<User>(Collections.USERS, userId);
if (!user || !user.resume) {
setError('Resume not found');
setUser(null);
return;
}
// Map to our simplified format
setUser({
id: user.id,
name: user.name,
email: user.email,
major: user.major,
graduation_year: user.graduation_year,
resume: user.resume,
avatar: user.avatar
});
} catch (err) {
console.error('Error loading resume details:', err);
setError('Failed to load resume details');
setUser(null);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex justify-center items-center py-10">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="alert alert-error">
<div className="flex-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mx-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<label>{error}</label>
</div>
</div>
);
}
if (!user) {
return (
<div className="text-center py-10">
<p className="text-base-content/70">Select a resume to view details</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Student Information */}
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-shrink-0">
<div className="avatar">
<div className="w-24 h-24 rounded-xl">
{user.avatar ? (
<img src={user.avatar} alt={user.name} />
) : (
<div className="bg-primary text-primary-content flex items-center justify-center w-full h-full">
<span className="text-2xl font-bold">{user.name.charAt(0)}</span>
</div>
)}
</div>
</div>
</div>
<div className="flex-grow">
<h3 className="text-xl font-bold">{user.name}</h3>
<p className="text-base-content/70">{user.email}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<h4 className="text-sm font-semibold text-base-content/50">Major</h4>
<p>{user.major || 'Not specified'}</p>
</div>
<div>
<h4 className="text-sm font-semibold text-base-content/50">Graduation Year</h4>
<p>{user.graduation_year || 'Not specified'}</p>
</div>
</div>
</div>
</div>
{/* Resume Preview */}
<div className="border border-base-300 rounded-lg overflow-hidden">
<div className="bg-base-200 px-4 py-2 border-b border-base-300 flex justify-between items-center">
<h3 className="font-medium">Resume</h3>
<a
href={user.resume}
target="_blank"
rel="noopener noreferrer"
className="btn btn-sm btn-primary"
>
Download
</a>
</div>
<div className="p-4 bg-base-100">
{user.resume && user.resume.toLowerCase().endsWith('.pdf') ? (
<div className="aspect-[8.5/11] w-full">
<iframe
src={`${user.resume}#toolbar=0&navpanes=0`}
className="w-full h-full border-0"
title={`${user.name}'s Resume`}
/>
</div>
) : (
<div className="text-center py-10">
<p className="text-base-content/70">
Resume preview not available. Click the download button to view the resume.
</p>
</div>
)}
</div>
</div>
{/* Contact Button */}
<div className="flex justify-end">
<a
href={`mailto:${user.email}?subject=Regarding%20Your%20Resume&body=Hello%20${user.name},%0A%0AI%20found%20your%20resume%20in%20the%20IEEE%20UCSD%20database%20and%20would%20like%20to%20discuss%20potential%20opportunities.%0A%0ABest%20regards,`}
className="btn btn-secondary"
>
Contact Student
</a>
</div>
</div>
);
}

View file

@ -0,0 +1,172 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { User } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
export default function ResumeFilters() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [majors, setMajors] = useState<string[]>([]);
const [graduationYears, setGraduationYears] = useState<number[]>([]);
// Filter state
const [selectedMajor, setSelectedMajor] = useState<string>('all');
const [selectedGraduationYear, setSelectedGraduationYear] = useState<string>('all');
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
loadFilterOptions();
// Listen for refresh requests
const handleRefresh = () => {
loadFilterOptions();
};
window.addEventListener('resumeDatabaseRefresh', handleRefresh);
return () => {
window.removeEventListener('resumeDatabaseRefresh', handleRefresh);
};
}, []);
// When filters change, dispatch event to notify parent
useEffect(() => {
dispatchFilterChange();
}, [selectedMajor, selectedGraduationYear]);
const loadFilterOptions = async () => {
try {
setLoading(true);
// Get all users with resumes
const filter = "resume != null && resume != ''";
const users = await get.getAll<User>(Collections.USERS, filter);
// Extract unique majors
const uniqueMajors = new Set<string>();
users.forEach(user => {
if (user.major) {
uniqueMajors.add(user.major);
}
});
// Extract unique graduation years
const uniqueGradYears = new Set<number>();
users.forEach(user => {
if (user.graduation_year) {
uniqueGradYears.add(user.graduation_year);
}
});
// Sort majors alphabetically
const sortedMajors = Array.from(uniqueMajors).sort();
// Sort graduation years in ascending order
const sortedGradYears = Array.from(uniqueGradYears).sort((a, b) => a - b);
setMajors(sortedMajors);
setGraduationYears(sortedGradYears);
} catch (err) {
console.error('Error loading filter options:', err);
setError('Failed to load filter options');
} finally {
setLoading(false);
}
};
const dispatchFilterChange = () => {
window.dispatchEvent(
new CustomEvent('resumeFilterChange', {
detail: {
major: selectedMajor,
graduationYear: selectedGraduationYear
}
})
);
};
const handleMajorChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedMajor(e.target.value);
};
const handleGraduationYearChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedGraduationYear(e.target.value);
};
const handleResetFilters = () => {
setSelectedMajor('all');
setSelectedGraduationYear('all');
};
if (loading) {
return (
<div className="flex justify-center items-center py-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="alert alert-error">
<div className="flex-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mx-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<label>{error}</label>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Major Filter */}
<div className="form-control">
<label className="label">
<span className="label-text">Major</span>
</label>
<select
className="select select-bordered w-full"
value={selectedMajor}
onChange={handleMajorChange}
>
<option value="all">All Majors</option>
{majors.map(major => (
<option key={major} value={major}>{major}</option>
))}
</select>
</div>
{/* Graduation Year Filter */}
<div className="form-control">
<label className="label">
<span className="label-text">Graduation Year</span>
</label>
<select
className="select select-bordered w-full"
value={selectedGraduationYear}
onChange={handleGraduationYearChange}
>
<option value="all">All Years</option>
{graduationYears.map(year => (
<option key={year} value={year}>{year}</option>
))}
</select>
</div>
{/* Reset Filters Button */}
<div className="form-control mt-6">
<button
className="btn btn-outline btn-sm"
onClick={handleResetFilters}
>
Reset Filters
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,254 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { User } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
interface ResumeUser {
id: string;
name: string;
major?: string;
graduation_year?: number;
resume?: string;
avatar?: string;
}
export default function ResumeList() {
const [users, setUsers] = useState<ResumeUser[]>([]);
const [filteredUsers, setFilteredUsers] = useState<ResumeUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const usersPerPage = 10;
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
loadResumes();
// Listen for filter changes
const handleFilterChange = (event: CustomEvent) => {
applyFilters(event.detail);
};
// Listen for search changes
const handleSearchChange = (event: CustomEvent) => {
applySearch(event.detail.searchQuery);
};
// Listen for refresh requests
const handleRefresh = () => {
loadResumes();
};
window.addEventListener('resumeFilterChange', handleFilterChange as EventListener);
window.addEventListener('resumeSearchChange', handleSearchChange as EventListener);
window.addEventListener('resumeDatabaseRefresh', handleRefresh);
return () => {
window.removeEventListener('resumeFilterChange', handleFilterChange as EventListener);
window.removeEventListener('resumeSearchChange', handleSearchChange as EventListener);
window.removeEventListener('resumeDatabaseRefresh', handleRefresh);
};
}, []);
const loadResumes = async () => {
try {
setLoading(true);
// Get all users with resumes
const filter = "resume != null && resume != ''";
const users = await get.getAll<User>(Collections.USERS, filter);
// Map to our simplified format
const resumeUsers = users
.filter(user => user.resume) // Ensure resume exists
.map(user => ({
id: user.id,
name: user.name,
major: user.major,
graduation_year: user.graduation_year,
resume: user.resume,
avatar: user.avatar
}));
setUsers(resumeUsers);
setFilteredUsers(resumeUsers);
setCurrentPage(1);
} catch (err) {
console.error('Error loading resumes:', err);
setError('Failed to load resume data');
} finally {
setLoading(false);
}
};
const applyFilters = (filters: any) => {
let filtered = [...users];
// Apply major filter
if (filters.major && filters.major !== 'all') {
filtered = filtered.filter(user => {
if (!user.major) return false;
return user.major.toLowerCase().includes(filters.major.toLowerCase());
});
}
// Apply graduation year filter
if (filters.graduationYear && filters.graduationYear !== 'all') {
const year = parseInt(filters.graduationYear);
filtered = filtered.filter(user => user.graduation_year === year);
}
setFilteredUsers(filtered);
setCurrentPage(1);
};
const applySearch = (searchQuery: string) => {
if (!searchQuery.trim()) {
setFilteredUsers(users);
setCurrentPage(1);
return;
}
const query = searchQuery.toLowerCase();
const filtered = users.filter(user =>
user.name.toLowerCase().includes(query) ||
(user.major && user.major.toLowerCase().includes(query))
);
setFilteredUsers(filtered);
setCurrentPage(1);
};
const handleResumeClick = (userId: string) => {
// Dispatch event to notify parent component
window.dispatchEvent(
new CustomEvent('resumeSelected', {
detail: { resumeId: userId }
})
);
};
// Get current users for pagination
const indexOfLastUser = currentPage * usersPerPage;
const indexOfFirstUser = indexOfLastUser - usersPerPage;
const currentUsers = filteredUsers.slice(indexOfFirstUser, indexOfLastUser);
const totalPages = Math.ceil(filteredUsers.length / usersPerPage);
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
if (loading) {
return (
<div className="flex justify-center items-center py-10">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="alert alert-error">
<div className="flex-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mx-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<label>{error}</label>
</div>
</div>
);
}
if (filteredUsers.length === 0) {
return (
<div className="text-center py-10">
<p className="text-base-content/70">No resumes found matching your criteria</p>
</div>
);
}
return (
<div>
<div className="overflow-x-auto">
<table className="table w-full">
<thead>
<tr>
<th>Student</th>
<th>Major</th>
<th>Graduation Year</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{currentUsers.map(user => (
<tr key={user.id} className="hover">
<td>
<div className="flex items-center space-x-3">
<div className="avatar">
<div className="mask mask-squircle w-12 h-12">
{user.avatar ? (
<img src={user.avatar} alt={user.name} />
) : (
<div className="bg-primary text-primary-content flex items-center justify-center w-full h-full">
<span className="text-lg font-bold">{user.name.charAt(0)}</span>
</div>
)}
</div>
</div>
<div>
<div className="font-bold">{user.name}</div>
</div>
</div>
</td>
<td>{user.major || 'Not specified'}</td>
<td>{user.graduation_year || 'Not specified'}</td>
<td>
<button
className="btn btn-sm btn-primary"
onClick={() => handleResumeClick(user.id)}
>
View Resume
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center mt-6">
<div className="btn-group">
<button
className="btn btn-sm"
onClick={() => paginate(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
«
</button>
{Array.from({ length: totalPages }, (_, i) => (
<button
key={i + 1}
className={`btn btn-sm ${currentPage === i + 1 ? 'btn-active' : ''}`}
onClick={() => paginate(i + 1)}
>
{i + 1}
</button>
))}
<button
className="btn btn-sm"
onClick={() => paginate(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
>
»
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,73 @@
import { useState, useEffect } from 'react';
export default function ResumeSearch() {
const [searchQuery, setSearchQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
// Debounce search input to avoid too many updates
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(searchQuery);
}, 300);
return () => {
clearTimeout(timer);
};
}, [searchQuery]);
// When debounced query changes, dispatch event to notify parent
useEffect(() => {
dispatchSearchChange();
}, [debouncedQuery]);
const dispatchSearchChange = () => {
window.dispatchEvent(
new CustomEvent('resumeSearchChange', {
detail: {
searchQuery: debouncedQuery
}
})
);
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
};
const handleClearSearch = () => {
setSearchQuery('');
};
return (
<div className="relative">
<div className="flex items-center">
<div className="relative flex-grow">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg className="w-5 h-5 text-gray-500 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Search by name or major..."
className="w-full pl-10 pr-10 py-2 border border-gray-300 dark:border-gray-700 rounded-lg
bg-white/90 dark:bg-gray-800/90 text-gray-700 dark:text-gray-200 focus:outline-none
focus:ring-2 focus:ring-primary focus:border-transparent"
value={searchQuery}
onChange={handleSearchChange}
/>
{searchQuery && (
<button
className="absolute inset-y-0 right-0 flex items-center pr-3"
onClick={handleClearSearch}
>
<svg className="w-5 h-5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
</div>
);
}

View file

@ -1,99 +0,0 @@
---
// Sponsor Analytics Component
---
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Analytics Dashboard</h2>
<!-- Metrics Overview -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
<div class="stat bg-base-200 rounded-box p-4">
<div class="stat-title">Resume Downloads</div>
<div class="stat-value text-primary">89</div>
<div class="stat-desc">↗︎ 14 (30 days)</div>
</div>
<div class="stat bg-base-200 rounded-box p-4">
<div class="stat-title">Event Attendance</div>
<div class="stat-value">45</div>
<div class="stat-desc">↘︎ 5 (30 days)</div>
</div>
<div class="stat bg-base-200 rounded-box p-4">
<div class="stat-title">Student Interactions</div>
<div class="stat-value text-secondary">124</div>
<div class="stat-desc">↗︎ 32 (30 days)</div>
</div>
<div class="stat bg-base-200 rounded-box p-4">
<div class="stat-title">Workshop Engagement</div>
<div class="stat-value">92%</div>
<div class="stat-desc">↗︎ 8% (30 days)</div>
</div>
</div>
<!-- Detailed Analytics -->
<div class="grid gap-6 md:grid-cols-2">
<!-- Event Performance -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Event Performance</h3>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Event</th>
<th>Attendance</th>
<th>Rating</th>
</tr>
</thead>
<tbody>
<tr>
<td>Tech Talk</td>
<td>32</td>
<td>4.8/5</td>
</tr>
<tr>
<td>Workshop</td>
<td>28</td>
<td>4.6/5</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Resume Analytics -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Resume Analytics</h3>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Major</th>
<th>Downloads</th>
<th>Trend</th>
</tr>
</thead>
<tbody>
<tr>
<td>Computer Science</td>
<td>45</td>
<td>↗︎</td>
</tr>
<tr>
<td>Electrical Engineering</td>
<td>32</td>
<td>↗︎</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,334 @@
---
import EventAttendanceChart from "./SponsorAnalyticsSection/EventAttendanceChart";
import EventTypeDistribution from "./SponsorAnalyticsSection/EventTypeDistribution";
import MajorDistribution from "./SponsorAnalyticsSection/MajorDistribution";
import EventEngagementMetrics from "./SponsorAnalyticsSection/EventEngagementMetrics";
import EventTimeline from "./SponsorAnalyticsSection/EventTimeline";
---
<div class="space-y-6">
<div
class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4"
>
<div>
<h2 class="text-2xl font-bold">Event Analytics</h2>
<p class="text-base-content/70">
Insights and analytics about IEEE UCSD events and student
engagement
</p>
</div>
<div class="flex items-center gap-2">
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm">
<span>Time Range</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-down"
>
<path d="m6 9 6 6 6-6"></path>
</svg>
</div>
<ul
tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"
>
<li><a data-time-range="30">Last 30 Days</a></li>
<li><a data-time-range="90">Last 90 Days</a></li>
<li><a data-time-range="180">Last 6 Months</a></li>
<li><a data-time-range="365">Last Year</a></li>
<li><a data-time-range="all">All Time</a></li>
</ul>
</div>
<button id="refreshAnalyticsBtn" class="btn btn-sm btn-outline">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-refresh-cw"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
></path>
<path d="M21 3v5h-5"></path>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
></path>
<path d="M3 21v-5h5"></path>
</svg>
<span>Refresh</span>
</button>
</div>
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<h3 class="text-sm font-medium text-base-content/70">
Total Events
</h3>
<p class="text-3xl font-bold" id="totalEventsCount">--</p>
<div class="text-xs text-base-content/50 mt-1">
<span id="eventsTrend" class="font-medium"></span> vs previous
period
</div>
</div>
</div>
<div class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<h3 class="text-sm font-medium text-base-content/70">
Total Attendees
</h3>
<p class="text-3xl font-bold" id="totalAttendeesCount">--</p>
<div class="text-xs text-base-content/50 mt-1">
<span id="attendeesTrend" class="font-medium"></span> vs previous
period
</div>
</div>
</div>
<div class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<h3 class="text-sm font-medium text-base-content/70">
Unique Students
</h3>
<p class="text-3xl font-bold" id="uniqueStudentsCount">--</p>
<div class="text-xs text-base-content/50 mt-1">
<span id="uniqueStudentsTrend" class="font-medium"></span> vs
previous period
</div>
</div>
</div>
<div class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<h3 class="text-sm font-medium text-base-content/70">
Avg. Attendance
</h3>
<p class="text-3xl font-bold" id="avgAttendanceCount">--</p>
<div class="text-xs text-base-content/50 mt-1">
<span id="avgAttendanceTrend" class="font-medium"></span> vs
previous period
</div>
</div>
</div>
</div>
<!-- Charts Row 1 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h3 class="card-title text-lg">Event Attendance Over Time</h3>
<div class="h-80">
<EventAttendanceChart client:load />
</div>
</div>
</div>
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h3 class="card-title text-lg">Major Distribution</h3>
<div class="h-80">
<MajorDistribution client:load />
</div>
</div>
</div>
</div>
<!-- Charts Row 2 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h3 class="card-title text-lg">Event Type Distribution</h3>
<div class="h-80">
<EventTypeDistribution client:load />
</div>
</div>
</div>
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h3 class="card-title text-lg">Engagement Metrics</h3>
<div class="h-80">
<EventEngagementMetrics client:load />
</div>
</div>
</div>
</div>
<!-- Event Timeline -->
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h3 class="card-title text-lg">Event Timeline</h3>
<div class="overflow-x-auto">
<EventTimeline client:load />
</div>
</div>
</div>
</div>
<script>
import { Authentication } from "../../scripts/pocketbase/Authentication";
import { Get } from "../../scripts/pocketbase/Get";
import { Realtime } from "../../scripts/pocketbase/Realtime";
import { Collections } from "../../schemas/pocketbase/schema";
// Initialize services
const auth = Authentication.getInstance();
const get = Get.getInstance();
const realtime = Realtime.getInstance();
// Default time range (30 days)
let currentTimeRange = 30;
// Initialize the analytics dashboard
async function initAnalytics() {
if (!auth.isAuthenticated()) {
console.error("User not authenticated");
return;
}
try {
await loadSummaryData(currentTimeRange);
// Set up event listeners
document
.querySelectorAll("[data-time-range]")
.forEach((element) => {
element.addEventListener("click", (e) => {
const range =
parseInt(
e.currentTarget.getAttribute("data-time-range")
) || 30;
currentTimeRange = isNaN(range) ? "all" : range;
loadSummaryData(currentTimeRange);
});
});
// Refresh button
document
.getElementById("refreshAnalyticsBtn")
?.addEventListener("click", () => {
loadSummaryData(currentTimeRange);
});
// Set up realtime updates
setupRealtimeUpdates();
} catch (error) {
console.error("Error initializing analytics:", error);
}
}
// Load summary data
async function loadSummaryData(timeRange) {
try {
// Calculate date range
const endDate = new Date();
let startDate;
if (timeRange === "all") {
startDate = new Date(0); // Beginning of time
} else {
startDate = new Date();
startDate.setDate(startDate.getDate() - timeRange);
}
// Format dates for filter
const startDateStr = startDate.toISOString();
const endDateStr = endDate.toISOString();
// Build filter
const filter =
timeRange === "all"
? "published = true"
: `published = true && start_date >= "${startDateStr}" && start_date <= "${endDateStr}"`;
// Get events
const events = await get.getAll(Collections.EVENTS, filter);
// Get event attendees
const attendeesFilter =
timeRange === "all"
? ""
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
const attendees = await get.getAll(
Collections.EVENT_ATTENDEES,
attendeesFilter
);
// Calculate metrics
const totalEvents = events.length;
const totalAttendees = attendees.length;
// Calculate unique students
const uniqueStudentIds = new Set(attendees.map((a) => a.user));
const uniqueStudents = uniqueStudentIds.size;
// Calculate average attendance
const avgAttendance =
totalEvents > 0 ? Math.round(totalAttendees / totalEvents) : 0;
// Update UI
document.getElementById("totalEventsCount").textContent =
totalEvents;
document.getElementById("totalAttendeesCount").textContent =
totalAttendees;
document.getElementById("uniqueStudentsCount").textContent =
uniqueStudents;
document.getElementById("avgAttendanceCount").textContent =
avgAttendance;
// Calculate trends (simplified - would need previous period data for real implementation)
document.getElementById("eventsTrend").textContent = "+5%";
document.getElementById("attendeesTrend").textContent = "+12%";
document.getElementById("uniqueStudentsTrend").textContent = "+8%";
document.getElementById("avgAttendanceTrend").textContent = "+3%";
// Dispatch custom event to notify charts to update
window.dispatchEvent(
new CustomEvent("analyticsDataUpdated", {
detail: {
events,
attendees,
timeRange,
},
})
);
} catch (error) {
console.error("Error loading summary data:", error);
}
}
// Set up realtime updates
function setupRealtimeUpdates() {
// Subscribe to events collection
realtime.subscribeToCollection(Collections.EVENTS, (data) => {
console.log("Event data updated:", data);
loadSummaryData(currentTimeRange);
});
// Subscribe to event attendees collection
realtime.subscribeToCollection(Collections.EVENT_ATTENDEES, (data) => {
console.log("Attendee data updated:", data);
loadSummaryData(currentTimeRange);
});
}
// Initialize when document is ready
document.addEventListener("DOMContentLoaded", initAnalytics);
</script>

View file

@ -0,0 +1,249 @@
import { useState, useEffect, useRef } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { Event, EventAttendee } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
// Import Chart.js
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
BarElement,
} from 'chart.js';
import type { ChartOptions } from 'chart.js';
import { Line } from 'react-chartjs-2';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend
);
export default function EventAttendanceChart() {
const [chartData, setChartData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timeRange, setTimeRange] = useState<number | string>(30); // Default to 30 days
const chartRef = useRef<any>(null);
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
// Listen for analytics data updates from the parent component
const handleAnalyticsUpdate = (event: CustomEvent) => {
const { events, attendees, timeRange } = event.detail;
setTimeRange(timeRange);
processChartData(events, attendees);
};
window.addEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
// Initial data load
loadData();
return () => {
window.removeEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
};
}, []);
const loadData = async () => {
try {
setLoading(true);
// Calculate date range
const endDate = new Date();
let startDate;
if (timeRange === "all") {
startDate = new Date(0); // Beginning of time
} else {
startDate = new Date();
startDate.setDate(startDate.getDate() - (typeof timeRange === 'number' ? timeRange : 30));
}
// Format dates for filter
const startDateStr = startDate.toISOString();
const endDateStr = endDate.toISOString();
// Build filter
const filter = timeRange === "all"
? "published = true"
: `published = true && start_date >= "${startDateStr}" && start_date <= "${endDateStr}"`;
// Get events
const events = await get.getAll<Event>(Collections.EVENTS, filter);
// Get event attendees
const attendeesFilter = timeRange === "all"
? ""
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
const attendees = await get.getAll<EventAttendee>(Collections.EVENT_ATTENDEES, attendeesFilter);
processChartData(events, attendees);
} catch (err) {
console.error('Error loading event attendance data:', err);
setError('Failed to load event attendance data');
} finally {
setLoading(false);
}
};
const processChartData = (events: Event[], attendees: EventAttendee[]) => {
if (!events || events.length === 0) {
setChartData(null);
return;
}
// Group events by date
const eventsByDate = new Map<string, Event[]>();
events.forEach(event => {
// Format date to YYYY-MM-DD
const date = new Date(event.start_date);
const dateStr = date.toISOString().split('T')[0];
if (!eventsByDate.has(dateStr)) {
eventsByDate.set(dateStr, []);
}
eventsByDate.get(dateStr)!.push(event);
});
// Count attendees per event
const attendeesByEvent = new Map<string, number>();
attendees.forEach(attendee => {
if (!attendeesByEvent.has(attendee.event)) {
attendeesByEvent.set(attendee.event, 0);
}
attendeesByEvent.set(attendee.event, attendeesByEvent.get(attendee.event)! + 1);
});
// Calculate average attendance per date
const attendanceByDate = new Map<string, { total: number, count: number }>();
events.forEach(event => {
const date = new Date(event.start_date);
const dateStr = date.toISOString().split('T')[0];
if (!attendanceByDate.has(dateStr)) {
attendanceByDate.set(dateStr, { total: 0, count: 0 });
}
const attendeeCount = attendeesByEvent.get(event.id) || 0;
const current = attendanceByDate.get(dateStr)!;
attendanceByDate.set(dateStr, {
total: current.total + attendeeCount,
count: current.count + 1
});
});
// Sort dates
const sortedDates = Array.from(attendanceByDate.keys()).sort();
// Calculate average attendance per date
const averageAttendance = sortedDates.map(date => {
const { total, count } = attendanceByDate.get(date)!;
return count > 0 ? Math.round(total / count) : 0;
});
// Format dates for display
const formattedDates = sortedDates.map(date => {
const [year, month, day] = date.split('-');
return `${month}/${day}`;
});
// Create chart data
const data = {
labels: formattedDates,
datasets: [
{
label: 'Average Attendance',
data: averageAttendance,
borderColor: 'rgba(75, 192, 192, 1)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.4,
fill: true,
}
]
};
setChartData(data);
};
const chartOptions: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
tooltip: {
mode: 'index',
intersect: false,
},
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Average Attendance'
}
},
x: {
title: {
display: true,
text: 'Date'
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-error">{error}</div>
</div>
);
}
if (!chartData) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-center text-base-content/70">
<p>No event data available for the selected time period</p>
</div>
</div>
);
}
return (
<div className="h-full">
<Line data={chartData} options={chartOptions} />
</div>
);
}

View file

@ -0,0 +1,334 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { Event, EventAttendee, User } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
// Import Chart.js
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
RadialLinearScale,
} from 'chart.js';
import { Radar } from 'react-chartjs-2';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
RadialLinearScale,
Title,
Tooltip,
Legend
);
export default function EventEngagementMetrics() {
const [chartData, setChartData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timeRange, setTimeRange] = useState<number | string>(30); // Default to 30 days
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
// Listen for analytics data updates from the parent component
const handleAnalyticsUpdate = (event: CustomEvent) => {
const { events, attendees, timeRange } = event.detail;
setTimeRange(timeRange);
loadUserData(events, attendees);
};
window.addEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
// Initial data load
loadData();
return () => {
window.removeEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
};
}, []);
const loadData = async () => {
try {
setLoading(true);
// Calculate date range
const endDate = new Date();
let startDate;
if (timeRange === "all") {
startDate = new Date(0); // Beginning of time
} else {
startDate = new Date();
startDate.setDate(startDate.getDate() - (typeof timeRange === 'number' ? timeRange : 30));
}
// Format dates for filter
const startDateStr = startDate.toISOString();
const endDateStr = endDate.toISOString();
// Build filter
const filter = timeRange === "all"
? "published = true"
: `published = true && start_date >= "${startDateStr}" && start_date <= "${endDateStr}"`;
// Get events
const events = await get.getAll<Event>(Collections.EVENTS, filter);
// Get event attendees
const attendeesFilter = timeRange === "all"
? ""
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
const attendees = await get.getAll<EventAttendee>(Collections.EVENT_ATTENDEES, attendeesFilter);
await loadUserData(events, attendees);
} catch (err) {
console.error('Error loading engagement metrics data:', err);
setError('Failed to load engagement metrics data');
} finally {
setLoading(false);
}
};
const loadUserData = async (events: Event[], attendees: EventAttendee[]) => {
try {
if (!events || events.length === 0 || !attendees || attendees.length === 0) {
setChartData(null);
return;
}
// Get unique user IDs from attendees
const userIds = [...new Set(attendees.map(a => a.user))];
// Fetch user data to get graduation years
const users = await get.getMany<User>(Collections.USERS, userIds);
processChartData(events, attendees, users);
} catch (err) {
console.error('Error loading user data:', err);
setError('Failed to load user data');
}
};
const processChartData = (events: Event[], attendees: EventAttendee[], users: User[]) => {
if (!events || events.length === 0 || !attendees || attendees.length === 0) {
setChartData(null);
return;
}
// Create a map of user IDs to graduation years
const userGradYearMap = new Map<string, number>();
users.forEach(user => {
if (user.graduation_year) {
userGradYearMap.set(user.id, user.graduation_year);
}
});
// Calculate metrics
// 1. Attendance by time of day
const timeOfDayAttendance = {
'Morning (8am-12pm)': 0,
'Afternoon (12pm-5pm)': 0,
'Evening (5pm-9pm)': 0,
'Night (9pm-8am)': 0,
};
events.forEach(event => {
const startDate = new Date(event.start_date);
const hour = startDate.getHours();
// Count the event in the appropriate time slot
if (hour >= 8 && hour < 12) {
timeOfDayAttendance['Morning (8am-12pm)']++;
} else if (hour >= 12 && hour < 17) {
timeOfDayAttendance['Afternoon (12pm-5pm)']++;
} else if (hour >= 17 && hour < 21) {
timeOfDayAttendance['Evening (5pm-9pm)']++;
} else {
timeOfDayAttendance['Night (9pm-8am)']++;
}
});
// 2. Attendance by day of week
const dayOfWeekAttendance = {
'Sunday': 0,
'Monday': 0,
'Tuesday': 0,
'Wednesday': 0,
'Thursday': 0,
'Friday': 0,
'Saturday': 0,
};
const daysOfWeek = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
events.forEach(event => {
const startDate = new Date(event.start_date);
const dayOfWeek = daysOfWeek[startDate.getDay()];
// Use type assertion to avoid TypeScript error
(dayOfWeekAttendance as Record<string, number>)[dayOfWeek]++;
});
// 3. Attendance by graduation year
const gradYearAttendance: Record<string, number> = {};
attendees.forEach(attendee => {
const userId = attendee.user;
const gradYear = userGradYearMap.get(userId);
if (gradYear) {
const gradYearStr = gradYear.toString();
if (!gradYearAttendance[gradYearStr]) {
gradYearAttendance[gradYearStr] = 0;
}
gradYearAttendance[gradYearStr]++;
}
});
// 4. Food vs. No Food events
const foodEvents = events.filter(event => event.has_food).length;
const noFoodEvents = events.length - foodEvents;
// 5. Average attendance per event
const attendanceByEvent = new Map<string, number>();
attendees.forEach(attendee => {
if (!attendanceByEvent.has(attendee.event)) {
attendanceByEvent.set(attendee.event, 0);
}
attendanceByEvent.set(attendee.event, attendanceByEvent.get(attendee.event)! + 1);
});
const avgAttendance = events.length > 0
? Math.round(attendees.length / events.length)
: 0;
// Prepare radar chart data
// Normalize all metrics to a 0-100 scale for the radar chart
const maxTimeOfDay = Math.max(...Object.values(timeOfDayAttendance));
const maxDayOfWeek = Math.max(...Object.values(dayOfWeekAttendance));
const foodRatio = events.length > 0 ? (foodEvents / events.length) * 100 : 0;
// Calculate repeat attendance rate (% of users who attended more than one event)
const userAttendanceCounts = new Map<string, number>();
attendees.forEach(attendee => {
if (!userAttendanceCounts.has(attendee.user)) {
userAttendanceCounts.set(attendee.user, 0);
}
userAttendanceCounts.set(attendee.user, userAttendanceCounts.get(attendee.user)! + 1);
});
const repeatAttendees = [...userAttendanceCounts.values()].filter(count => count > 1).length;
const repeatRate = userAttendanceCounts.size > 0
? (repeatAttendees / userAttendanceCounts.size) * 100
: 0;
// Normalize metrics for radar chart (0-100 scale)
const normalizeValue = (value: number, max: number) => max > 0 ? (value / max) * 100 : 0;
const radarData = {
labels: [
'Morning Events',
'Afternoon Events',
'Evening Events',
'Weekday Events',
'Weekend Events',
'Food Events',
'Repeat Attendance'
],
datasets: [
{
label: 'Engagement Metrics',
data: [
normalizeValue(timeOfDayAttendance['Morning (8am-12pm)'], maxTimeOfDay),
normalizeValue(timeOfDayAttendance['Afternoon (12pm-5pm)'], maxTimeOfDay),
normalizeValue(timeOfDayAttendance['Evening (5pm-9pm)'], maxTimeOfDay),
normalizeValue(
dayOfWeekAttendance['Monday'] +
dayOfWeekAttendance['Tuesday'] +
dayOfWeekAttendance['Wednesday'] +
dayOfWeekAttendance['Thursday'] +
dayOfWeekAttendance['Friday'],
maxDayOfWeek * 5
),
normalizeValue(
dayOfWeekAttendance['Saturday'] +
dayOfWeekAttendance['Sunday'],
maxDayOfWeek * 2
),
foodRatio,
repeatRate
],
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 2,
pointBackgroundColor: 'rgba(54, 162, 235, 1)',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: 'rgba(54, 162, 235, 1)',
}
]
};
setChartData(radarData);
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top' as const,
},
tooltip: {
callbacks: {
label: function (context: any) {
return `${context.label}: ${Math.round(context.raw)}%`;
}
}
}
},
};
if (loading) {
return (
<div className="flex justify-center items-center h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-error">{error}</div>
</div>
);
}
if (!chartData) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-center text-base-content/70">
<p>No event data available for the selected time period</p>
</div>
</div>
);
}
return (
<div className="h-full">
<Radar data={chartData} options={chartOptions} />
</div>
);
}

View file

@ -0,0 +1,198 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { Event, EventAttendee } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
export default function EventTimeline() {
const [events, setEvents] = useState<Event[]>([]);
const [attendeesByEvent, setAttendeesByEvent] = useState<Map<string, number>>(new Map());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timeRange, setTimeRange] = useState<number | string>(30); // Default to 30 days
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
// Listen for analytics data updates from the parent component
const handleAnalyticsUpdate = (event: CustomEvent) => {
const { events, attendees, timeRange } = event.detail;
setTimeRange(timeRange);
processData(events, attendees);
};
window.addEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
// Initial data load
loadData();
return () => {
window.removeEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
};
}, []);
const loadData = async () => {
try {
setLoading(true);
// Calculate date range
const endDate = new Date();
let startDate;
if (timeRange === "all") {
startDate = new Date(0); // Beginning of time
} else {
startDate = new Date();
startDate.setDate(startDate.getDate() - (typeof timeRange === 'number' ? timeRange : 30));
}
// Format dates for filter
const startDateStr = startDate.toISOString();
const endDateStr = endDate.toISOString();
// Build filter
const filter = timeRange === "all"
? "published = true"
: `published = true && start_date >= "${startDateStr}" && start_date <= "${endDateStr}"`;
// Get events
const events = await get.getAll<Event>(Collections.EVENTS, filter, "start_date");
// Get event attendees
const attendeesFilter = timeRange === "all"
? ""
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
const attendees = await get.getAll<EventAttendee>(Collections.EVENT_ATTENDEES, attendeesFilter);
processData(events, attendees);
} catch (err) {
console.error('Error loading event timeline data:', err);
setError('Failed to load event timeline data');
} finally {
setLoading(false);
}
};
const processData = (events: Event[], attendees: EventAttendee[]) => {
if (!events || events.length === 0) {
setEvents([]);
setAttendeesByEvent(new Map());
return;
}
// Sort events by date (newest first)
const sortedEvents = [...events].sort((a, b) => {
return new Date(b.start_date).getTime() - new Date(a.start_date).getTime();
});
// Count attendees per event
const attendeesByEvent = new Map<string, number>();
attendees.forEach(attendee => {
if (!attendeesByEvent.has(attendee.event)) {
attendeesByEvent.set(attendee.event, 0);
}
attendeesByEvent.set(attendee.event, attendeesByEvent.get(attendee.event)! + 1);
});
setEvents(sortedEvents);
setAttendeesByEvent(attendeesByEvent);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const formatDuration = (startDate: string, endDate: string) => {
const start = new Date(startDate);
const end = new Date(endDate);
const durationMs = end.getTime() - start.getTime();
const hours = Math.floor(durationMs / (1000 * 60 * 60));
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours === 0) {
return `${minutes} min`;
} else if (minutes === 0) {
return `${hours} hr`;
} else {
return `${hours} hr ${minutes} min`;
}
};
if (loading) {
return (
<div className="flex justify-center items-center py-10">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="alert alert-error">
<div className="flex-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mx-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<label>{error}</label>
</div>
</div>
);
}
if (events.length === 0) {
return (
<div className="text-center py-10">
<p className="text-base-content/70">No events found for the selected time period</p>
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="table w-full">
<thead>
<tr>
<th>Event Name</th>
<th>Date</th>
<th>Duration</th>
<th>Location</th>
<th>Attendees</th>
<th>Food</th>
</tr>
</thead>
<tbody>
{events.map(event => (
<tr key={event.id} className="hover">
<td className="font-medium">{event.event_name}</td>
<td>{formatDate(event.start_date)}</td>
<td>{formatDuration(event.start_date, event.end_date)}</td>
<td>{event.location}</td>
<td>
<div className="badge badge-primary">
{attendeesByEvent.get(event.id) || 0}
</div>
</td>
<td>
{event.has_food ? (
<div className="badge badge-success">Yes</div>
) : (
<div className="badge badge-ghost">No</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,225 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { Event, EventAttendee } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
// Import Chart.js
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend,
CategoryScale,
LinearScale,
} from 'chart.js';
import { Pie } from 'react-chartjs-2';
// Register Chart.js components
ChartJS.register(
ArcElement,
Tooltip,
Legend,
CategoryScale,
LinearScale
);
// Define event types and their colors
const EVENT_TYPES = [
{ name: 'Technical Workshop', color: 'rgba(54, 162, 235, 0.8)' },
{ name: 'Social', color: 'rgba(255, 99, 132, 0.8)' },
{ name: 'Professional Development', color: 'rgba(75, 192, 192, 0.8)' },
{ name: 'Project Meeting', color: 'rgba(255, 206, 86, 0.8)' },
{ name: 'Industry Talk', color: 'rgba(153, 102, 255, 0.8)' },
{ name: 'Other', color: 'rgba(255, 159, 64, 0.8)' },
];
export default function EventTypeDistribution() {
const [chartData, setChartData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timeRange, setTimeRange] = useState<number | string>(30); // Default to 30 days
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
// Listen for analytics data updates from the parent component
const handleAnalyticsUpdate = (event: CustomEvent) => {
const { events, attendees, timeRange } = event.detail;
setTimeRange(timeRange);
processChartData(events, attendees);
};
window.addEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
// Initial data load
loadData();
return () => {
window.removeEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
};
}, []);
const loadData = async () => {
try {
setLoading(true);
// Calculate date range
const endDate = new Date();
let startDate;
if (timeRange === "all") {
startDate = new Date(0); // Beginning of time
} else {
startDate = new Date();
startDate.setDate(startDate.getDate() - (typeof timeRange === 'number' ? timeRange : 30));
}
// Format dates for filter
const startDateStr = startDate.toISOString();
const endDateStr = endDate.toISOString();
// Build filter
const filter = timeRange === "all"
? "published = true"
: `published = true && start_date >= "${startDateStr}" && start_date <= "${endDateStr}"`;
// Get events
const events = await get.getAll<Event>(Collections.EVENTS, filter);
// Get event attendees
const attendeesFilter = timeRange === "all"
? ""
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
const attendees = await get.getAll<EventAttendee>(Collections.EVENT_ATTENDEES, attendeesFilter);
processChartData(events, attendees);
} catch (err) {
console.error('Error loading event type distribution data:', err);
setError('Failed to load event type distribution data');
} finally {
setLoading(false);
}
};
const processChartData = (events: Event[], attendees: EventAttendee[]) => {
if (!events || events.length === 0) {
setChartData(null);
return;
}
// Categorize events by type
// For this demo, we'll use a simple heuristic based on event name/description
// In a real implementation, you might have an event_type field in your schema
const eventTypeCount = EVENT_TYPES.reduce((acc, type) => {
acc[type.name] = 0;
return acc;
}, {} as Record<string, number>);
events.forEach(event => {
const eventName = event.event_name.toLowerCase();
const eventDesc = event.event_description.toLowerCase();
// Simple classification logic - in a real app, you'd have actual event types
if (eventName.includes('workshop') || eventDesc.includes('workshop')) {
eventTypeCount['Technical Workshop']++;
} else if (eventName.includes('social') || eventDesc.includes('social') ||
eventName.includes('mixer') || eventDesc.includes('mixer')) {
eventTypeCount['Social']++;
} else if (eventName.includes('professional') || eventDesc.includes('professional') ||
eventName.includes('resume') || eventDesc.includes('resume') ||
eventName.includes('career') || eventDesc.includes('career')) {
eventTypeCount['Professional Development']++;
} else if (eventName.includes('project') || eventDesc.includes('project') ||
eventName.includes('meeting') || eventDesc.includes('meeting')) {
eventTypeCount['Project Meeting']++;
} else if (eventName.includes('industry') || eventDesc.includes('industry') ||
eventName.includes('talk') || eventDesc.includes('talk') ||
eventName.includes('speaker') || eventDesc.includes('speaker')) {
eventTypeCount['Industry Talk']++;
} else {
eventTypeCount['Other']++;
}
});
// Prepare data for chart
const labels = Object.keys(eventTypeCount);
const data = Object.values(eventTypeCount);
const backgroundColor = labels.map(label =>
EVENT_TYPES.find(type => type.name === label)?.color || 'rgba(128, 128, 128, 0.8)'
);
const chartData = {
labels,
datasets: [
{
data,
backgroundColor,
borderColor: backgroundColor.map(color => color.replace('0.8', '1')),
borderWidth: 1,
},
],
};
setChartData(chartData);
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right' as const,
labels: {
padding: 20,
boxWidth: 12,
},
},
tooltip: {
callbacks: {
label: function (context: any) {
const label = context.label || '';
const value = context.raw || 0;
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0);
const percentage = Math.round((value / total) * 100);
return `${label}: ${value} (${percentage}%)`;
}
}
}
},
};
if (loading) {
return (
<div className="flex justify-center items-center h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-error">{error}</div>
</div>
);
}
if (!chartData) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-center text-base-content/70">
<p>No event data available for the selected time period</p>
</div>
</div>
);
}
return (
<div className="h-full">
<Pie data={chartData} options={chartOptions} />
</div>
);
}

View file

@ -0,0 +1,262 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { User, EventAttendee } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
// Import Chart.js
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from 'chart.js';
import { Bar } from 'react-chartjs-2';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
);
// Define major categories and their colors
const MAJOR_CATEGORIES = [
{ name: 'Computer Science', color: 'rgba(54, 162, 235, 0.8)' },
{ name: 'Electrical Engineering', color: 'rgba(255, 99, 132, 0.8)' },
{ name: 'Computer Engineering', color: 'rgba(75, 192, 192, 0.8)' },
{ name: 'Mechanical Engineering', color: 'rgba(255, 206, 86, 0.8)' },
{ name: 'Data Science', color: 'rgba(153, 102, 255, 0.8)' },
{ name: 'Mathematics', color: 'rgba(255, 159, 64, 0.8)' },
{ name: 'Physics', color: 'rgba(201, 203, 207, 0.8)' },
{ name: 'Other Engineering', color: 'rgba(100, 149, 237, 0.8)' },
{ name: 'Other', color: 'rgba(169, 169, 169, 0.8)' },
];
export default function MajorDistribution() {
const [chartData, setChartData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timeRange, setTimeRange] = useState<number | string>(30); // Default to 30 days
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
// Listen for analytics data updates from the parent component
const handleAnalyticsUpdate = (event: CustomEvent) => {
const { events, attendees, timeRange } = event.detail;
setTimeRange(timeRange);
loadUserData(attendees);
};
window.addEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
// Initial data load
loadData();
return () => {
window.removeEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
};
}, []);
const loadData = async () => {
try {
setLoading(true);
// Calculate date range
const endDate = new Date();
let startDate;
if (timeRange === "all") {
startDate = new Date(0); // Beginning of time
} else {
startDate = new Date();
startDate.setDate(startDate.getDate() - (typeof timeRange === 'number' ? timeRange : 30));
}
// Format dates for filter
const startDateStr = startDate.toISOString();
const endDateStr = endDate.toISOString();
// Get event attendees
const attendeesFilter = timeRange === "all"
? ""
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
const attendees = await get.getAll<EventAttendee>(Collections.EVENT_ATTENDEES, attendeesFilter);
await loadUserData(attendees);
} catch (err) {
console.error('Error loading major distribution data:', err);
setError('Failed to load major distribution data');
} finally {
setLoading(false);
}
};
const loadUserData = async (attendees: EventAttendee[]) => {
try {
if (!attendees || attendees.length === 0) {
setChartData(null);
return;
}
// Get unique user IDs from attendees
const userIds = [...new Set(attendees.map(a => a.user))];
// Fetch user data to get majors
const users = await get.getMany<User>(Collections.USERS, userIds);
processChartData(users);
} catch (err) {
console.error('Error loading user data:', err);
setError('Failed to load user data');
}
};
const processChartData = (users: User[]) => {
if (!users || users.length === 0) {
setChartData(null);
return;
}
// Categorize users by major
const majorCounts = MAJOR_CATEGORIES.reduce((acc, category) => {
acc[category.name] = 0;
return acc;
}, {} as Record<string, number>);
users.forEach(user => {
if (!user.major) {
majorCounts['Other']++;
return;
}
const major = user.major.toLowerCase();
// Categorize majors
if (major.includes('computer science') || major.includes('cs')) {
majorCounts['Computer Science']++;
} else if (major.includes('electrical') || major.includes('ee')) {
majorCounts['Electrical Engineering']++;
} else if (major.includes('computer eng') || major.includes('ce')) {
majorCounts['Computer Engineering']++;
} else if (major.includes('mechanical') || major.includes('me')) {
majorCounts['Mechanical Engineering']++;
} else if (major.includes('data science') || major.includes('ds')) {
majorCounts['Data Science']++;
} else if (major.includes('math')) {
majorCounts['Mathematics']++;
} else if (major.includes('physics')) {
majorCounts['Physics']++;
} else if (major.includes('engineering')) {
majorCounts['Other Engineering']++;
} else {
majorCounts['Other']++;
}
});
// Sort by count (descending)
const sortedMajors = Object.entries(majorCounts)
.sort((a, b) => b[1] - a[1])
.filter(([_, count]) => count > 0); // Only include majors with at least one student
// Prepare data for chart
const labels = sortedMajors.map(([major]) => major);
const data = sortedMajors.map(([_, count]) => count);
const backgroundColor = labels.map(label =>
MAJOR_CATEGORIES.find(category => category.name === label)?.color || 'rgba(128, 128, 128, 0.8)'
);
const chartData = {
labels,
datasets: [
{
label: 'Number of Students',
data,
backgroundColor,
borderColor: backgroundColor.map(color => color.replace('0.8', '1')),
borderWidth: 1,
},
],
};
setChartData(chartData);
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y' as const,
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
label: function (context: any) {
const label = context.label || '';
const value = context.raw || 0;
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0);
const percentage = Math.round((value / total) * 100);
return `${value} students (${percentage}%)`;
}
}
}
},
scales: {
x: {
beginAtZero: true,
title: {
display: true,
text: 'Number of Students'
}
},
y: {
title: {
display: true,
text: 'Major'
}
}
},
};
if (loading) {
return (
<div className="flex justify-center items-center h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-error">{error}</div>
</div>
);
}
if (!chartData) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-center text-base-content/70">
<p>No student data available for the selected time period</p>
</div>
</div>
);
}
return (
<div className="h-full">
<Bar data={chartData} options={chartOptions} />
</div>
);
}

View file

@ -1,78 +0,0 @@
---
// Sponsor Dashboard Component
---
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Sponsor Dashboard</h2>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- Sponsorship Status -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Sponsorship Status</h3>
<p class="text-primary font-semibold">Active</p>
<p class="text-sm opacity-70">Valid until: Dec 31, 2024</p>
</div>
</div>
<!-- Partnership Level -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Partnership Level</h3>
<p class="text-primary font-semibold">Platinum</p>
<p class="text-sm opacity-70">All benefits included</p>
</div>
</div>
<!-- Quick Actions -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Quick Actions</h3>
<div class="flex gap-2 mt-2">
<button class="btn btn-primary btn-sm"
>Contact Us</button
>
<button class="btn btn-outline btn-sm"
>View Contract</button
>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="mt-6">
<h3 class="text-xl font-semibold mb-4">Recent Activity</h3>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Activity</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>2024-01-15</td>
<td>Resume Book Access</td>
<td
><span class="badge badge-success"
>Completed</span
></td
>
</tr>
<tr>
<td>2024-01-10</td>
<td>Workshop Scheduling</td>
<td
><span class="badge badge-warning">Pending</span
></td
>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>

View file

@ -65,19 +65,19 @@ sections:
class: "text-info hover:text-info-focus" class: "text-info hover:text-info-focus"
# Sponsor Menu # Sponsor Menu
sponsorDashboard:
title: "Sponsor Dashboard"
icon: "heroicons:briefcase"
role: "sponsor"
component: "SponsorDashboard"
class: "text-warning hover:text-warning-focus"
sponsorAnalytics: sponsorAnalytics:
title: "Analytics" title: "Event Analytics"
icon: "heroicons:chart-bar" icon: "heroicons:chart-bar"
role: "sponsor" role: "sponsor"
component: "SponsorAnalytics" component: "SponsorAnalyticsSection"
class: "text-warning hover:text-warning-focus" class: "text-primary hover:text-primary-focus"
resumeDatabase:
title: "Resume Database"
icon: "heroicons:document-text"
role: "sponsor"
component: "ResumeDatabase"
class: "text-secondary hover:text-secondary-focus"
# Administrator Menu # Administrator Menu
adminDashboard: adminDashboard:
@ -126,7 +126,7 @@ categories:
sponsor: sponsor:
title: "Sponsor Portal" title: "Sponsor Portal"
sections: ["sponsorDashboard", "sponsorAnalytics"] sections: ["sponsorAnalytics", "resumeDatabase"]
role: "sponsor" role: "sponsor"
account: account: