initial sponsor page
This commit is contained in:
parent
43c1fc074a
commit
12207546de
14 changed files with 2480 additions and 188 deletions
187
src/components/dashboard/ResumeDatabase.astro
Normal file
187
src/components/dashboard/ResumeDatabase.astro
Normal 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>
|
181
src/components/dashboard/ResumeDatabase/ResumeDetail.tsx
Normal file
181
src/components/dashboard/ResumeDatabase/ResumeDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
172
src/components/dashboard/ResumeDatabase/ResumeFilters.tsx
Normal file
172
src/components/dashboard/ResumeDatabase/ResumeFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
254
src/components/dashboard/ResumeDatabase/ResumeList.tsx
Normal file
254
src/components/dashboard/ResumeDatabase/ResumeList.tsx
Normal 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>
|
||||
);
|
||||
}
|
73
src/components/dashboard/ResumeDatabase/ResumeSearch.tsx
Normal file
73
src/components/dashboard/ResumeDatabase/ResumeSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
334
src/components/dashboard/SponsorAnalyticsSection.astro
Normal file
334
src/components/dashboard/SponsorAnalyticsSection.astro
Normal 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>
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
|
@ -65,19 +65,19 @@ sections:
|
|||
class: "text-info hover:text-info-focus"
|
||||
|
||||
# Sponsor Menu
|
||||
sponsorDashboard:
|
||||
title: "Sponsor Dashboard"
|
||||
icon: "heroicons:briefcase"
|
||||
role: "sponsor"
|
||||
component: "SponsorDashboard"
|
||||
class: "text-warning hover:text-warning-focus"
|
||||
|
||||
sponsorAnalytics:
|
||||
title: "Analytics"
|
||||
title: "Event Analytics"
|
||||
icon: "heroicons:chart-bar"
|
||||
role: "sponsor"
|
||||
component: "SponsorAnalytics"
|
||||
class: "text-warning hover:text-warning-focus"
|
||||
component: "SponsorAnalyticsSection"
|
||||
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
|
||||
adminDashboard:
|
||||
|
@ -126,7 +126,7 @@ categories:
|
|||
|
||||
sponsor:
|
||||
title: "Sponsor Portal"
|
||||
sections: ["sponsorDashboard", "sponsorAnalytics"]
|
||||
sections: ["sponsorAnalytics", "resumeDatabase"]
|
||||
role: "sponsor"
|
||||
|
||||
account:
|
||||
|
|
Loading…
Reference in a new issue