Compare commits

..

65 commits
test ... main

Author SHA1 Message Date
chark1es
f1aa06f764 Update Dockerfile 2025-06-16 18:43:47 -07:00
chark1es
7fd9ff922a Update docker-compose.yml 2025-06-16 18:39:13 -07:00
chark1es
0825c281f2 Update Dockerfile 2025-06-16 18:37:33 -07:00
chark1es
f97bbb0872 Update Dockerfile 2025-06-16 18:35:25 -07:00
chark1es
f4ea7209f5 Update Dockerfile 2025-06-16 18:24:34 -07:00
chark1es
6af12e59b7 add dockerfile 2025-06-16 18:21:18 -07:00
chark1es
0d9b435097 ss 2025-06-16 12:54:08 -07:00
chark1es
7d4e695d30 use svg instead of puppeteer 2025-06-16 12:35:32 -07:00
chark1es
32848a9a06 add better progress email integration 2025-06-16 12:09:58 -07:00
chark1es
b831e89f49 Update send-reimbursement-email.ts 2025-06-16 01:42:11 -07:00
chark1es
8f6b9806a9 improve reimbursement status 2025-06-07 20:39:34 -07:00
chark1es
eea220639c send email to treasurer 2025-06-07 18:58:27 -07:00
chark1es
be09ab6c44 Fix filtering 2025-06-01 00:25:27 -07:00
chark1es
014d9492ac improve data sync 2025-05-30 23:06:44 -07:00
chark1es
4349b4d034 fix file uploads 2025-05-30 23:00:56 -07:00
chark1es
a57a4e6889 add officer role update 2025-05-29 15:40:46 -07:00
chark1es
40b2ea48c1 split files for organization 2025-05-29 12:27:38 -07:00
chark1es
f75e1c6de1 Update send-reimbursement-notification.ts 2025-05-29 11:56:03 -07:00
chark1es
aac2837b78 add event request notifications 2025-05-29 11:46:46 -07:00
chark1es
5d92bcfd1b add email notifications 2025-05-29 01:10:25 -07:00
chark1es
0584f160b2 Update nixpacks.toml 2025-05-28 10:26:47 -07:00
chark1es
e8c932c3ce Update package.json 2025-05-28 10:24:24 -07:00
chark1es
a1e415aee4 improve ui/ux for officer management 2025-05-28 10:16:03 -07:00
chark1es
61089b0472 reformat ui/ux 2025-05-28 10:12:30 -07:00
chark1es
52504aeb21 improve reimbursement submissions 2025-05-28 09:26:51 -07:00
chark1es
216f48a572 improve navigation and added filters 2025-05-28 09:09:53 -07:00
chark1es
0b1ee708b9 separate email to tab 2025-05-28 01:42:36 -07:00
chark1es
0c2fd1a8c2 fix events time issue 2025-05-28 01:42:24 -07:00
chark1es
b5e9e599aa Update .gitignore 2025-05-28 01:42:07 -07:00
chark1es
216808ee18 Revert "upgrade to tailwindcss v4"
This reverts commit 16ecd96ec6.
2025-05-01 13:58:14 -07:00
chark1es
16ecd96ec6 upgrade to tailwindcss v4 2025-04-28 13:33:28 -07:00
chark1es
fb64e3421a Merge branch 'main' of https://git.ieeeucsd.org/Webmaster/ieeeucsd-org 2025-04-15 16:45:34 -07:00
chark1es
a303b7565b Update Contacts.astro 2025-04-15 16:45:27 -07:00
chark1es
f2bbd65db5 update types 2025-04-11 17:37:32 -07:00
chark1es
effb8e22d7 Merge branch 'main' of https://git.ieeeucsd.org/Webmaster/dev-ieeeucsd-org 2025-04-10 19:31:47 -07:00
chark1es
dff2679543 fix resumes not showing 2025-04-10 19:31:45 -07:00
chark1es
c8b12d7dff Update OfficerManagement.tsx 2025-04-08 23:53:04 -07:00
chark1es
137a68c867 Add event type 2025-04-08 21:43:06 -07:00
chark1es
1bfdf2cc31 improve event details 2025-04-08 19:33:19 -07:00
chark1es
ad8abf9eaa fix officer replacement 2025-04-07 17:10:40 -07:00
chark1es
a714891543 fix officer addon 2025-04-07 16:34:55 -07:00
chark1es
eb865dbd4c Update bun.lock 2025-04-07 15:47:46 -07:00
chark1es
27dd80481c fix broken url 2025-04-07 15:42:30 -07:00
chark1es
2a99e180bd update typo 2025-04-07 15:39:14 -07:00
chark1es
12207546de initial sponsor page 2025-04-05 15:30:26 -07:00
chark1es
43c1fc074a Revert "partially fix nav"
This reverts commit 2eaf5a230e.
2025-04-05 12:35:48 -07:00
chark1es
2eaf5a230e partially fix nav 2025-04-05 12:29:23 -07:00
chark1es
3533dc8048 redefined logout fix 2025-04-05 03:24:10 -07:00
chark1es
961ef1437a Add a way to add members as officers 2025-04-05 03:23:59 -07:00
chark1es
1dec703780 add budget limit check 2025-04-04 01:04:27 -07:00
chark1es
9e2345c0f7 fix logout 2025-04-02 20:11:32 -07:00
chark1es
a935417a31 removed 0 not being a default number 2025-04-02 19:06:24 -07:00
chark1es
60ffada68f fix duplicate toast showing 2025-04-02 18:50:51 -07:00
chark1es
80f104a7d4 fixed the attendance not properly working 2025-04-02 18:39:31 -07:00
chark1es
4b3079a7f7 change wording for as food 2025-04-02 18:23:42 -07:00
chark1es
d1fb2bc1c5 add color to files button 2025-04-02 18:12:52 -07:00
chark1es
236186bd39 fix receipt model showing on audit 2025-04-02 18:12:44 -07:00
chark1es
1f62063ed7 remove the word loyalty 2025-04-02 17:24:15 -07:00
chark1es
733f3dc931 fix database fetching 2025-04-02 17:00:37 -07:00
chark1es
10d3f32fbc streamlined the dates more 2025-04-02 14:12:53 -07:00
chark1es
e68627f666 remove unused imports 2025-04-01 15:34:57 -07:00
chark1es
11d5c4ad22 Merge branch 'main' of https://git.ieeeucsd.org/Webmaster/dev-ieeeucsd-org 2025-04-01 14:23:26 -07:00
chark1es
4fdb29e8b0 fix reimbursement issues per steph 2025-04-01 14:23:23 -07:00
chark1es
c0200a72a1 added erik format 2025-03-31 14:16:15 -07:00
chark1es
ee67e0678e Fix event request 2025-03-31 01:53:19 -07:00
80 changed files with 13990 additions and 2192 deletions

1
.cursorignore Normal file
View file

@ -0,0 +1 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)

2
.gitignore vendored
View file

@ -5,6 +5,8 @@ dist/
.astro/ .astro/
.cursor .cursor
final_review_gate.py
# dependencies # dependencies
node_modules/ node_modules/

55
Dockerfile Normal file
View file

@ -0,0 +1,55 @@
# Use the official Bun image
FROM oven/bun:1.1
# Install dependencies for Puppeteer and Chrome/Chromium
RUN apt-get update && \
apt-get install -y \
wget \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libx11-xcb1 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
xdg-utils \
chromium \
gnupg \
--no-install-recommends && \
# Install Google Chrome stable
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - && \
echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list && \
apt-get update && \
apt-get install -y google-chrome-stable && \
rm -rf /var/lib/apt/lists/*
# Set Puppeteer executable path (prefer google-chrome-stable, fallback to chromium)
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable
# Set working directory
WORKDIR /app
# Copy package files and install dependencies
COPY bun.lock package.json ./
RUN bun install
# Copy the rest of your app
COPY . .
# Build the application
RUN bun run build
# Expose the port your app runs on (change if needed)
EXPOSE 4321
# Start the server
CMD ["bun", "run", "start"]

1542
bun.lock

File diff suppressed because it is too large Load diff

12
docker-compose.yml Normal file
View file

@ -0,0 +1,12 @@
version: "3.8"
services:
web:
build: .
ports:
- "4321:4321"
environment:
- NODE_ENV=production
- PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable
- PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
- PUPPETEER_DISABLE_SANDBOX=true

View file

@ -1,4 +1,4 @@
[phases.setup] [phases.setup]
nixPkgs = ["nodejs_18", "bun"] nixPkgs = ["nodejs_20", "bun"]
aptPkgs = ["curl", "wget"] aptPkgs = ["curl", "wget"]

View file

@ -2,6 +2,9 @@
"name": "ieeeucsd-dev", "name": "ieeeucsd-dev",
"type": "module", "type": "module",
"version": "0.0.1", "version": "0.0.1",
"engines": {
"node": ">=18.20.8"
},
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "node ./dist/server/entry.mjs", "start": "node ./dist/server/entry.mjs",
@ -10,10 +13,11 @@
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"@astrojs/mdx": "4.0.3", "@astrojs/check": "^0.9.4",
"@astrojs/node": "^9.0.0", "@astrojs/mdx": "^4.2.3",
"@astrojs/react": "^4.2.0", "@astrojs/node": "^9.1.3",
"@astrojs/tailwind": "5.1.4", "@astrojs/react": "^4.2.3",
"@astrojs/tailwind": "^6.0.2",
"@heroui/react": "^2.7.5", "@heroui/react": "^2.7.5",
"@iconify-json/heroicons": "^1.2.2", "@iconify-json/heroicons": "^1.2.2",
"@iconify-json/mdi": "^1.2.3", "@iconify-json/mdi": "^1.2.3",
@ -21,9 +25,10 @@
"@types/highlight.js": "^10.1.0", "@types/highlight.js": "^10.1.0",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.15", "@types/lodash": "^4.17.15",
"@types/react": "^19.0.8", "@types/puppeteer": "^7.0.4",
"@types/react-dom": "^19.0.3", "@types/react": "^19.1.0",
"astro": "5.1.1", "@types/react-dom": "^19.1.1",
"astro": "^5.5.6",
"astro-expressive-code": "^0.40.2", "astro-expressive-code": "^0.40.2",
"astro-icon": "^1.1.5", "astro-icon": "^1.1.5",
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
@ -37,13 +42,16 @@
"next": "^15.1.2", "next": "^15.1.2",
"pocketbase": "^0.25.1", "pocketbase": "^0.25.1",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"react": "^19.0.0", "puppeteer": "^24.10.1",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0", "react-dom": "^19.1.0",
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"rehype-expressive-code": "^0.40.2", "rehype-expressive-code": "^0.40.2",
"tailwindcss": "^3.4.16" "resend": "^4.5.1",
"tailwindcss": "^3.4.16",
"typescript": "^5.8.3"
}, },
"devDependencies": { "devDependencies": {
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",

View file

@ -23,13 +23,12 @@ import EventLoad from "./EventsSection/EventLoad";
<!-- Event Registration Card --> <!-- Event Registration Card -->
<div class="w-full"> <div class="w-full">
<div <div
class="card bg-card shadow-xl border border-border opacity-50 cursor-not-allowed relative group h-full" class="card bg-base-100 shadow-xl border border-base-200 opacity-50 cursor-not-allowed relative group h-full"
> >
<div <div
class="absolute inset-0 bg-card opacity-0 group-hover:opacity-90 transition-opacity duration-300 flex items-center justify-center z-10" class="absolute inset-0 bg-base-100 opacity-0 group-hover:opacity-90 transition-opacity duration-300 flex items-center justify-center z-10"
> >
<span <span class="text-base-content font-medium text-sm sm:text-base"
class="text-card-foreground font-medium text-sm sm:text-base"
>Coming Soon</span >Coming Soon</span
> >
</div> </div>
@ -37,28 +36,24 @@ import EventLoad from "./EventsSection/EventLoad";
<h3 class="card-title text-base sm:text-lg mb-3 sm:mb-4"> <h3 class="card-title text-base sm:text-lg mb-3 sm:mb-4">
Event Registration Event Registration
</h3> </h3>
<div class="w-full"> <div class="form-control w-full">
<label class="block text-sm sm:text-base mb-2"> <label class="label">
<span class="text-sm sm:text-base" <span class="label-text text-sm sm:text-base"
>Select an event to register</span >Select an event to register</span
> >
</label> </label>
<div class="flex flex-col sm:flex-row gap-2"> <div class="flex flex-col sm:flex-row gap-2">
<select <select
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 flex-1 text-sm sm:text-base min-h-[2.5rem]" class="select select-bordered flex-1 text-sm sm:text-base h-10 min-h-[2.5rem] w-full"
disabled disabled
> >
<option disabled selected>Pick an event</option> <option disabled selected>Pick an event</option>
<option <option>Technical Workshop - Web Development</option>
>Technical Workshop - Web Development</option <option>Professional Development Workshop</option>
>
<option
>Professional Development Workshop</option
>
<option>Social Event - Game Night</option> <option>Social Event - Game Night</option>
</select> </select>
<button <button
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 min-h-[2.5rem] w-full sm:w-[100px] px-4 py-2" class="btn btn-primary text-sm sm:text-base h-10 min-h-[2.5rem] w-full sm:w-[100px]"
disabled>Register</button disabled>Register</button
> >
</div> </div>
@ -94,9 +89,8 @@ import EventLoad from "./EventsSection/EventLoad";
class="btn btn-circle btn-ghost btn-sm sm:btn-md" class="btn btn-circle btn-ghost btn-sm sm:btn-md"
onclick="window.closeEventDetailsModal()" onclick="window.closeEventDetailsModal()"
> >
<iconify-icon <iconify-icon icon="heroicons:x-mark" className="h-4 w-4 sm:h-6 sm:w-6"
icon="heroicons:x-mark" ></iconify-icon>
className="h-4 w-4 sm:h-6 sm:w-6"></iconify-icon>
</button> </button>
</div> </div>
@ -130,8 +124,7 @@ import EventLoad from "./EventsSection/EventLoad";
id="previewLoadingSpinner" id="previewLoadingSpinner"
class="absolute inset-0 flex items-center justify-center bg-base-200 bg-opacity-50 hidden" class="absolute inset-0 flex items-center justify-center bg-base-200 bg-opacity-50 hidden"
> >
<span class="loading loading-spinner loading-md sm:loading-lg" <span class="loading loading-spinner loading-md sm:loading-lg"></span>
></span>
</div> </div>
<div id="previewContent" class="w-full"> <div id="previewContent" class="w-full">
<FilePreview client:load isModal={true} /> <FilePreview client:load isModal={true} />
@ -210,7 +203,7 @@ import EventLoad from "./EventsSection/EventLoad";
if (!filename || typeof filename !== "string") { if (!filename || typeof filename !== "string") {
console.error( console.error(
"Invalid filename provided to previewFileEvents:", "Invalid filename provided to previewFileEvents:",
filename filename,
); );
toast.error("Cannot preview file: Invalid filename"); toast.error("Cannot preview file: Invalid filename");
return; return;
@ -218,10 +211,7 @@ import EventLoad from "./EventsSection/EventLoad";
// Ensure URL is properly formatted // Ensure URL is properly formatted
if (!url.startsWith("http")) { if (!url.startsWith("http")) {
console.warn( console.warn("URL doesn't start with http, attempting to fix:", url);
"URL doesn't start with http, attempting to fix:",
url
);
if (url.startsWith("/")) { if (url.startsWith("/")) {
url = `https://pocketbase.ieeeucsd.org${url}`; url = `https://pocketbase.ieeeucsd.org${url}`;
} else { } else {
@ -231,7 +221,7 @@ import EventLoad from "./EventsSection/EventLoad";
} }
const modal = document.getElementById( const modal = document.getElementById(
"filePreviewModal" "filePreviewModal",
) as HTMLDialogElement; ) as HTMLDialogElement;
const previewFileName = document.getElementById("previewFileName"); const previewFileName = document.getElementById("previewFileName");
const previewContent = document.getElementById("previewContent"); const previewContent = document.getElementById("previewContent");
@ -270,7 +260,7 @@ import EventLoad from "./EventsSection/EventLoad";
background: "#FFC107", background: "#FFC107",
color: "#000", color: "#000",
}, },
} },
); );
} }
}) })
@ -286,7 +276,7 @@ import EventLoad from "./EventsSection/EventLoad";
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("filePreviewStateChange", { new CustomEvent("filePreviewStateChange", {
detail: { url, filename }, detail: { url, filename },
}) }),
); );
}); });
@ -306,7 +296,7 @@ import EventLoad from "./EventsSection/EventLoad";
window.closeFilePreviewEvents = function () { window.closeFilePreviewEvents = function () {
// console.log("closeFilePreviewEvents called"); // console.log("closeFilePreviewEvents called");
const modal = document.getElementById( const modal = document.getElementById(
"filePreviewModal" "filePreviewModal",
) as HTMLDialogElement; ) as HTMLDialogElement;
const previewFileName = document.getElementById("previewFileName"); const previewFileName = document.getElementById("previewFileName");
const previewContent = document.getElementById("previewContent"); const previewContent = document.getElementById("previewContent");
@ -324,7 +314,7 @@ import EventLoad from "./EventsSection/EventLoad";
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("filePreviewStateChange", { new CustomEvent("filePreviewStateChange", {
detail: { url: "", filename: "" }, detail: { url: "", filename: "" },
}) }),
); );
// Reset the UI // Reset the UI
@ -356,10 +346,10 @@ import EventLoad from "./EventsSection/EventLoad";
// Update the openDetailsModal function to use the events-specific preview // Update the openDetailsModal function to use the events-specific preview
window.openDetailsModal = function (event: any) { window.openDetailsModal = function (event: any) {
const modal = document.getElementById( const modal = document.getElementById(
"eventDetailsModal" "eventDetailsModal",
) as HTMLDialogElement; ) as HTMLDialogElement;
const filesContent = document.getElementById( const filesContent = document.getElementById(
"filesContent" "filesContent",
) as HTMLDivElement; ) as HTMLDivElement;
// Check if event has ended // Check if event has ended
@ -383,11 +373,7 @@ import EventLoad from "./EventsSection/EventLoad";
if (filesContent) filesContent.classList.remove("hidden"); if (filesContent) filesContent.classList.remove("hidden");
// Populate files content // Populate files content
if ( if (event.files && Array.isArray(event.files) && event.files.length > 0) {
event.files &&
Array.isArray(event.files) &&
event.files.length > 0
) {
const baseUrl = "https://pocketbase.ieeeucsd.org"; const baseUrl = "https://pocketbase.ieeeucsd.org";
const collectionId = "events"; const collectionId = "events";
const recordId = event.id; const recordId = event.id;
@ -446,7 +432,7 @@ import EventLoad from "./EventsSection/EventLoad";
// Add downloadAllFiles function // Add downloadAllFiles function
window.downloadAllFiles = async function () { window.downloadAllFiles = async function () {
const downloadBtn = document.getElementById( const downloadBtn = document.getElementById(
"downloadAllBtn" "downloadAllBtn",
) as HTMLButtonElement; ) as HTMLButtonElement;
if (!downloadBtn) return; if (!downloadBtn) return;
const originalBtnContent = downloadBtn.innerHTML; const originalBtnContent = downloadBtn.innerHTML;
@ -501,7 +487,7 @@ import EventLoad from "./EventsSection/EventLoad";
} catch (error: any) { } catch (error: any) {
console.error("Failed to download files:", error); console.error("Failed to download files:", error);
toast.error( toast.error(
error?.message || "Failed to download files. Please try again." error?.message || "Failed to download files. Please try again.",
); );
} finally { } finally {
// Reset button state // Reset button state
@ -513,7 +499,7 @@ import EventLoad from "./EventsSection/EventLoad";
// Close event details modal // Close event details modal
window.closeEventDetailsModal = function () { window.closeEventDetailsModal = function () {
const modal = document.getElementById( const modal = document.getElementById(
"eventDetailsModal" "eventDetailsModal",
) as HTMLDialogElement; ) as HTMLDialogElement;
const filesContent = document.getElementById("filesContent"); const filesContent = document.getElementById("filesContent");

View file

@ -7,11 +7,12 @@ import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { Collections } from "../../../schemas/pocketbase/schema"; import { Collections } from "../../../schemas/pocketbase/schema";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import type { Event, EventAttendee } from "../../../schemas/pocketbase"; import type { Event, EventAttendee, LimitedUser } from "../../../schemas/pocketbase";
// Extended Event interface with additional properties needed for this component // Extended Event interface with additional properties needed for this component
interface ExtendedEvent extends Event { interface ExtendedEvent extends Event {
description?: string; // This component uses 'description' but schema has 'event_description' description?: string; // This component uses 'description' but schema has 'event_description'
event_type: string; // Add event_type field from schema
} }
// Note: Date conversion is now handled automatically by the Get and Update classes. // Note: Date conversion is now handled automatically by the Get and Update classes.
@ -156,7 +157,12 @@ const EventCheckIn = () => {
); );
// Store event code in local storage for offline check-in // Store event code in local storage for offline check-in
try {
await dataSync.storeEventCode(eventCode); await dataSync.storeEventCode(eventCode);
} catch (syncError) {
// Log the error but don't show a toast to the user
console.error("Error storing event code locally:", syncError);
}
// Show event details toast only for non-food events // Show event details toast only for non-food events
// For food events, we'll show the toast after food selection // For food events, we'll show the toast after food selection
@ -180,7 +186,7 @@ const EventCheckIn = () => {
<div> <div>
<strong>Event with food found!</strong> <strong>Event with food found!</strong>
<p className="text-sm mt-1">{event.event_name}</p> <p className="text-sm mt-1">{event.event_name}</p>
<p className="text-xs mt-1">Please select your food preference</p> <p className="text-xs mt-1">Please select the food you ate (or will eat) at the event!</p>
</div>, </div>,
{ duration: 5000 } { duration: 5000 }
); );
@ -264,23 +270,61 @@ const EventCheckIn = () => {
totalPoints += attendee.points_earned || 0; totalPoints += attendee.points_earned || 0;
}); });
// Log the points update // Update the LimitedUser record with the new points total
// console.log(`Updating user points to: ${totalPoints}`); try {
// Try to get the LimitedUser record to check if it exists
let limitedUserExists = false;
try {
const limitedUser = await get.getOne(Collections.LIMITED_USERS, userId);
limitedUserExists = !!limitedUser;
} catch (e) {
// Record doesn't exist
limitedUserExists = false;
}
// Update the user record with the new total points // Create or update the LimitedUser record
await update.updateFields(Collections.USERS, userId, { if (limitedUserExists) {
points: totalPoints await update.updateFields(Collections.LIMITED_USERS, userId, {
points: JSON.stringify(totalPoints),
total_events_attended: JSON.stringify(userAttendance.totalItems)
}); });
} else {
// Get user data to create LimitedUser record
const userData = await get.getOne(Collections.USERS, userId);
if (userData) {
await update.create(Collections.LIMITED_USERS, {
id: userId, // Use same ID as user record
name: userData.name || 'Anonymous User',
major: userData.major || '',
points: JSON.stringify(totalPoints),
total_events_attended: JSON.stringify(userAttendance.totalItems)
});
}
}
} catch (error) {
console.error('Failed to update LimitedUser record:', error);
}
// Ensure local data is in sync with backend // Ensure local data is in sync with backend
// First sync the new attendance record // First sync the new attendance record
try {
await dataSync.syncCollection(Collections.EVENT_ATTENDEES); await dataSync.syncCollection(Collections.EVENT_ATTENDEES);
// Then sync the updated user data to ensure points are correctly reflected locally // Then sync the updated user and LimitedUser data
await dataSync.syncCollection(Collections.USERS); await dataSync.syncCollection(Collections.USERS);
await dataSync.syncCollection(Collections.LIMITED_USERS);
} catch (syncError) {
// Log the error but don't show a toast to the user
console.error('Local sync failed:', syncError);
}
// Clear event code from local storage // Clear event code from local storage
try {
await dataSync.clearEventCode(); await dataSync.clearEventCode();
} catch (clearError) {
// Log the error but don't show a toast to the user
console.error("Error clearing event code from local storage:", clearError);
}
// Log successful check-in // Log successful check-in
await logger.send( await logger.send(
@ -359,12 +403,12 @@ const EventCheckIn = () => {
return ( return (
<> <>
<div className="card bg-card shadow-xl border border-border h-full"> <div className="card bg-base-100 shadow-xl border border-base-200 h-full">
<div className="card-body p-4 sm:p-6"> <div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Event Check-in</h3> <h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Event Check-in</h3>
<div className="w-full"> <div className="form-control w-full">
<label className="block text-sm sm:text-base mb-2"> <label className="label">
<span className="text-sm sm:text-base">Enter event code to check in</span> <span className="label-text text-sm sm:text-base">Enter event code to check in</span>
</label> </label>
<form onSubmit={(e) => { <form onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
@ -428,12 +472,12 @@ const EventCheckIn = () => {
<div className="badge badge-primary mb-4"> <div className="badge badge-primary mb-4">
{currentCheckInEvent?.points_to_reward} points {currentCheckInEvent?.points_to_reward} points
</div> </div>
<p className="mb-4">This event has food! Please let us know what you'd like to eat:</p> <p className="mb-4">This event has food! Please let us know what you ate (or will eat):</p>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="form-control"> <div className="form-control">
<input <input
type="text" type="text"
placeholder="Enter your food preference" placeholder="Enter the food you will or are eating"
className="input input-bordered w-full" className="input input-bordered w-full"
value={foodInput} value={foodInput}
onChange={(e) => setFoodInput(e.target.value)} onChange={(e) => setFoodInput(e.target.value)}

View file

@ -10,6 +10,7 @@ import type { Event, AttendeeEntry, EventAttendee } from "../../../schemas/pocke
// Extended Event interface with additional properties needed for this component // Extended Event interface with additional properties needed for this component
interface ExtendedEvent extends Event { interface ExtendedEvent extends Event {
description?: string; // This component uses 'description' but schema has 'event_description' description?: string; // This component uses 'description' but schema has 'event_description'
event_type: string; // Add event_type field from schema
} }
declare global { declare global {
@ -62,6 +63,19 @@ const EventLoad = () => {
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle'); const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [expandedDescriptions, setExpandedDescriptions] = useState<Set<string>>(new Set());
const toggleDescription = (eventId: string) => {
setExpandedDescriptions(prev => {
const newSet = new Set(prev);
if (newSet.has(eventId)) {
newSet.delete(eventId);
} else {
newSet.add(eventId);
}
return newSet;
});
};
// Function to clear the events cache and force a fresh sync // Function to clear the events cache and force a fresh sync
const refreshEvents = async () => { const refreshEvents = async () => {
@ -103,28 +117,28 @@ const EventLoad = () => {
}, []); }, []);
const createSkeletonCard = () => ( const createSkeletonCard = () => (
<div className="card bg-base-200 dark:bg-gray-800/90 shadow-lg animate-pulse border border-base-300 dark:border-gray-700"> <div className="card bg-base-200 shadow-lg animate-pulse">
<div className="card-body p-5"> <div className="card-body p-5">
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex items-start justify-between gap-3 mb-2"> <div className="flex items-start justify-between gap-3 mb-2">
<div className="flex-1"> <div className="flex-1">
<div className="skeleton h-6 w-3/4 mb-2 bg-base-300 dark:bg-gray-700"></div> <div className="skeleton h-6 w-3/4 mb-2"></div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="skeleton h-5 w-16 bg-base-300 dark:bg-gray-700"></div> <div className="skeleton h-5 w-16"></div>
<div className="skeleton h-5 w-20 bg-base-300 dark:bg-gray-700"></div> <div className="skeleton h-5 w-20"></div>
</div> </div>
</div> </div>
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<div className="skeleton h-5 w-24 mb-1 bg-base-300 dark:bg-gray-700"></div> <div className="skeleton h-5 w-24 mb-1"></div>
<div className="skeleton h-4 w-16 bg-base-300 dark:bg-gray-700"></div> <div className="skeleton h-4 w-16"></div>
</div> </div>
</div> </div>
<div className="skeleton h-4 w-full mb-3 bg-base-300 dark:bg-gray-700"></div> <div className="skeleton h-4 w-full mb-3"></div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="skeleton h-4 w-4 bg-base-300 dark:bg-gray-700"></div> <div className="skeleton h-4 w-4"></div>
<div className="skeleton h-4 w-1/2 bg-base-300 dark:bg-gray-700"></div> <div className="skeleton h-4 w-1/2"></div>
</div> </div>
</div> </div>
</div> </div>
@ -146,7 +160,7 @@ const EventLoad = () => {
try { try {
const get = Get.getInstance(); const get = Get.getInstance();
const attendees = await get.getList<EventAttendee>( const attendees = await get.getList<EventAttendee>(
"event_attendees", Collections.EVENT_ATTENDEES,
1, 1,
1, 1,
`user="${currentUser.id}" && event="${event.id}"` `user="${currentUser.id}" && event="${event.id}"`
@ -154,12 +168,38 @@ const EventLoad = () => {
const hasAttendedEvent = attendees.totalItems > 0; const hasAttendedEvent = attendees.totalItems > 0;
// Store the attendance status in the window object with the event
const eventDataId = `event_${event.id}`;
if (window[eventDataId]) {
window[eventDataId].hasAttended = hasAttendedEvent;
}
// Update the card UI based on attendance status // Update the card UI based on attendance status
const cardElement = document.getElementById(`event-card-${event.id}`); const cardElement = document.getElementById(`event-card-${event.id}`);
if (cardElement && hasAttendedEvent) { if (cardElement) {
const attendedBadge = cardElement.querySelector('.attended-badge'); const attendedBadge = document.getElementById(`attendance-badge-${event.id}`);
if (attendedBadge) { if (attendedBadge && hasAttendedEvent) {
(attendedBadge as HTMLElement).style.display = 'flex'; attendedBadge.classList.remove('badge-ghost');
attendedBadge.classList.add('badge-success');
// Update the icon and text
const icon = attendedBadge.querySelector('svg');
if (icon) {
icon.setAttribute('icon', 'heroicons:check-circle');
}
// Update the text content
attendedBadge.textContent = '';
// Recreate the icon
const iconElement = document.createElement('span');
iconElement.className = 'h-3 w-3';
iconElement.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10zm-.997-6l7.07-7.071l-1.414-1.414l-5.656 5.657l-2.829-2.829l-1.414 1.414L11.003 16z"/></svg>';
attendedBadge.appendChild(iconElement);
// Add the text
const textNode = document.createTextNode(' Attended');
attendedBadge.appendChild(textNode);
} }
} }
} catch (error) { } catch (error) {
@ -176,48 +216,87 @@ const EventLoad = () => {
const endDate = new Date(event.end_date); const endDate = new Date(event.end_date);
const now = new Date(); const now = new Date();
const isPastEvent = endDate < now; const isPastEvent = endDate < now;
const isExpanded = expandedDescriptions.has(event.id);
const description = event.event_description || "No description available";
return ( return (
<div id={`event-card-${event.id}`} key={event.id} className="card bg-base-200 dark:bg-gray-800/90 shadow-lg hover:shadow-xl transition-all duration-300 relative overflow-hidden border border-base-300 dark:border-gray-700"> <div id={`event-card-${event.id}`} key={event.id} className="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-300 relative overflow-hidden">
<div className="card-body p-3 sm:p-4"> <div className="card-body p-4">
<div className="flex flex-col h-full"> {/* Event Header */}
<div className="flex flex-col gap-2"> <div className="flex justify-between items-start mb-2">
<div className="flex-1"> <h3 className="card-title text-base sm:text-lg font-semibold line-clamp-2">{event.event_name}</h3>
<h3 className="card-title text-base sm:text-lg font-semibold mb-1 line-clamp-2 text-gray-800 dark:text-gray-100">{event.event_name}</h3> <div className="badge badge-primary badge-sm flex-shrink-0">{event.points_to_reward} pts</div>
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm text-gray-600 dark:text-gray-300"> </div>
<div className="badge badge-primary badge-sm">{event.points_to_reward} pts</div>
<div className="text-xs sm:text-sm text-gray-600 dark:text-gray-300"> {/* Event Description */}
<div className="mb-3">
<p className={`text-xs sm:text-sm text-base-content/70 ${isExpanded ? '' : 'line-clamp-2'}`}>
{description}
</p>
{description.length > 80 && (
<button
onClick={() => toggleDescription(event.id)}
className="text-xs text-primary hover:text-primary-focus mt-1 flex items-center"
>
{isExpanded ? (
<>
<Icon icon="heroicons:chevron-up" className="h-3 w-3 mr-1" />
Show less
</>
) : (
<>
<Icon icon="heroicons:chevron-down" className="h-3 w-3 mr-1" />
Show more
</>
)}
</button>
)}
</div>
{/* Event Details */}
<div className="grid grid-cols-1 gap-1 mb-3 text-xs sm:text-sm">
<div className="flex items-center gap-2">
<Icon icon="heroicons:calendar" className="h-3.5 w-3.5 text-primary" />
<span>
{startDate.toLocaleDateString("en-US", { {startDate.toLocaleDateString("en-US", {
weekday: "short", weekday: "short",
month: "short", month: "short",
day: "numeric", day: "numeric",
})} })}
{" • "} </span>
</div>
<div className="flex items-center gap-2">
<Icon icon="heroicons:clock" className="h-3.5 w-3.5 text-primary" />
<span>
{startDate.toLocaleTimeString("en-US", { {startDate.toLocaleTimeString("en-US", {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
})} })}
</span>
</div> </div>
<div className="flex items-center gap-2">
<Icon icon="heroicons:map-pin" className="h-3.5 w-3.5 text-primary" />
<span className="line-clamp-1">{event.location || "No location specified"}</span>
</div> </div>
<div className="flex items-center gap-2">
<Icon icon="heroicons:tag" className="h-3.5 w-3.5 text-primary" />
<span className="line-clamp-1 capitalize">{event.event_type || "Other"}</span>
</div> </div>
</div> </div>
<div className="text-xs sm:text-sm text-gray-600 dark:text-gray-300 my-2 line-clamp-2"> {/* Action Buttons */}
{event.event_description || "No description available"} <div className="flex flex-wrap items-center gap-2 mt-auto">
</div>
<div className="flex flex-wrap items-center gap-2 mt-auto pt-2">
{event.files && event.files.length > 0 && ( {event.files && event.files.length > 0 && (
<button <button
onClick={() => window.openDetailsModal(event as ExtendedEvent)} onClick={() => window.openDetailsModal(event as ExtendedEvent)}
className="btn btn-ghost btn-sm text-xs sm:text-sm gap-1 h-8 min-h-0 px-2 text-gray-700 dark:text-gray-300" className="btn btn-accent btn-sm text-xs sm:text-sm gap-1 h-8 min-h-0 px-3 shadow-md hover:shadow-lg transition-all duration-300 rounded-full"
> >
<Icon icon="heroicons:document-duplicate" className="h-3 w-3 sm:h-4 sm:w-4" /> <Icon icon="heroicons:document-duplicate" className="h-3 w-3 sm:h-4 sm:w-4" />
Files ({event.files.length}) Files ({event.files.length})
</button> </button>
)} )}
{isPastEvent && ( {isPastEvent && (
<div className={`badge ${hasAttended ? 'badge-success' : 'badge-ghost'} text-xs gap-1 attended-badge ${!hasAttended ? 'hidden' : ''}`}> <div id={`attendance-badge-${event.id}`} className={`badge ${hasAttended ? 'badge-success' : 'badge-ghost'} text-xs gap-1 ml-auto`}>
<Icon <Icon
icon={hasAttended ? "heroicons:check-circle" : "heroicons:x-circle"} icon={hasAttended ? "heroicons:check-circle" : "heroicons:x-circle"}
className="h-3 w-3" className="h-3 w-3"
@ -225,10 +304,6 @@ const EventLoad = () => {
{hasAttended ? 'Attended' : 'Not Attended'} {hasAttended ? 'Attended' : 'Not Attended'}
</div> </div>
)} )}
<div className="text-xs sm:text-sm text-gray-600 dark:text-gray-400 ml-auto">
{event.location}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -480,9 +555,9 @@ const EventLoad = () => {
return ( return (
<> <>
{/* Ongoing Events */} {/* Ongoing Events */}
<div className="card bg-base-100 dark:bg-gray-900/90 shadow-xl border border-base-200 dark:border-gray-700 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6"> <div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6"> <div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">Ongoing Events</h3> <h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Ongoing Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{[...Array(3)].map((_, i) => ( {[...Array(3)].map((_, i) => (
<div key={`ongoing-skeleton-${i}`}>{createSkeletonCard()}</div> <div key={`ongoing-skeleton-${i}`}>{createSkeletonCard()}</div>
@ -492,9 +567,9 @@ const EventLoad = () => {
</div> </div>
{/* Upcoming Events */} {/* Upcoming Events */}
<div className="card bg-base-100 dark:bg-gray-900/90 shadow-xl border border-base-200 dark:border-gray-700 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6"> <div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6"> <div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">Upcoming Events</h3> <h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Upcoming Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{[...Array(3)].map((_, i) => ( {[...Array(3)].map((_, i) => (
<div key={`upcoming-skeleton-${i}`}>{createSkeletonCard()}</div> <div key={`upcoming-skeleton-${i}`}>{createSkeletonCard()}</div>
@ -504,9 +579,9 @@ const EventLoad = () => {
</div> </div>
{/* Past Events */} {/* Past Events */}
<div className="card bg-base-100 dark:bg-gray-900/90 shadow-xl border border-base-200 dark:border-gray-700 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mx-4 sm:mx-6"> <div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6"> <div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">Past Events</h3> <h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Past Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{[...Array(3)].map((_, i) => ( {[...Array(3)].map((_, i) => (
<div key={`past-skeleton-${i}`}>{createSkeletonCard()}</div> <div key={`past-skeleton-${i}`}>{createSkeletonCard()}</div>
@ -525,14 +600,14 @@ const EventLoad = () => {
<> <>
{/* No Events Message */} {/* No Events Message */}
{noEvents && ( {noEvents && (
<div className="card bg-base-100 dark:bg-gray-900/90 shadow-xl border border-base-200 dark:border-gray-700 mx-4 sm:mx-6 p-8"> <div className="card bg-base-100 shadow-xl border border-base-200 mx-4 sm:mx-6 p-8">
<div className="text-center"> <div className="text-center">
<Icon icon="heroicons:calendar" className="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" /> <Icon icon="heroicons:calendar" className="w-16 h-16 mx-auto text-base-content/30 mb-4" />
<h3 className="text-xl font-bold mb-2 text-gray-800 dark:text-gray-100">No Events Found</h3> <h3 className="text-xl font-bold mb-2">No Events Found</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4"> <p className="text-base-content/70 mb-4">
There are currently no events to display. This could be due to: There are currently no events to display. This could be due to:
</p> </p>
<ul className="list-disc text-left max-w-md mx-auto text-gray-600 dark:text-gray-300 mb-6"> <ul className="list-disc text-left max-w-md mx-auto text-base-content/70 mb-6">
<li className="mb-1">No events have been published yet</li> <li className="mb-1">No events have been published yet</li>
<li className="mb-1">There might be a connection issue with the event database</li> <li className="mb-1">There might be a connection issue with the event database</li>
<li className="mb-1">The events data might be temporarily unavailable</li> <li className="mb-1">The events data might be temporarily unavailable</li>
@ -560,9 +635,9 @@ const EventLoad = () => {
{/* Ongoing Events */} {/* Ongoing Events */}
{events.ongoing.length > 0 && ( {events.ongoing.length > 0 && (
<div className="card bg-base-100 dark:bg-gray-900/90 shadow-xl border border-base-200 dark:border-gray-700 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6"> <div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6"> <div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">Ongoing Events</h3> <h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Ongoing Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{events.ongoing.map(renderEventCard)} {events.ongoing.map(renderEventCard)}
</div> </div>
@ -572,9 +647,9 @@ const EventLoad = () => {
{/* Upcoming Events */} {/* Upcoming Events */}
{events.upcoming.length > 0 && ( {events.upcoming.length > 0 && (
<div className="card bg-base-100 dark:bg-gray-900/90 shadow-xl border border-base-200 dark:border-gray-700 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6"> <div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6"> <div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">Upcoming Events</h3> <h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Upcoming Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{events.upcoming.map(renderEventCard)} {events.upcoming.map(renderEventCard)}
</div> </div>
@ -584,9 +659,9 @@ const EventLoad = () => {
{/* Past Events */} {/* Past Events */}
{events.past.length > 0 && ( {events.past.length > 0 && (
<div className="card bg-base-100 dark:bg-gray-900/90 shadow-xl border border-base-200 dark:border-gray-700 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mx-4 sm:mx-6"> <div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6"> <div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">Past Events</h3> <h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Past Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{events.past.map(renderEventCard)} {events.past.map(renderEventCard)}
</div> </div>

View file

@ -1,6 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get'; import { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { LimitedUser } from '../../../schemas/pocketbase/schema';
interface LeaderboardStats { interface LeaderboardStats {
totalUsers: number; totalUsers: number;
@ -54,34 +56,50 @@ export default function LeaderboardStats() {
setLoading(true); setLoading(true);
// Get all users without sorting - we'll sort on client side // Get all users without sorting - we'll sort on client side
const response = await get.getList('limitedUser', 1, 500, '', '', { const response = await get.getList(Collections.LIMITED_USERS, 1, 500, '', '', {
fields: ['id', 'name', 'points'] fields: ['id', 'name', 'points']
}); });
// Parse points from JSON string and convert to number
const processedUsers = response.items.map((user: Partial<LimitedUser>) => {
let pointsValue = 0;
try {
if (user.points) {
// Parse the JSON string to get the points value
const pointsData = JSON.parse(user.points);
pointsValue = typeof pointsData === 'number' ? pointsData : 0;
}
} catch (e) {
console.error('Error parsing points data:', e);
}
return {
id: user.id,
name: user.name,
parsedPoints: pointsValue
};
});
// Filter out users with no points for the leaderboard stats // Filter out users with no points for the leaderboard stats
const leaderboardUsers = response.items const leaderboardUsers = processedUsers
.filter((user: any) => .filter(user => user.parsedPoints > 0)
user.points !== undefined &&
user.points !== null &&
user.points > 0
)
// Sort by points descending // Sort by points descending
.sort((a: any, b: any) => b.points - a.points); .sort((a, b) => b.parsedPoints - a.parsedPoints);
const totalUsers = leaderboardUsers.length; const totalUsers = leaderboardUsers.length;
const totalPoints = leaderboardUsers.reduce((sum: number, user: any) => sum + (user.points || 0), 0); const totalPoints = leaderboardUsers.reduce((sum: number, user) => sum + user.parsedPoints, 0);
const topScore = leaderboardUsers.length > 0 ? leaderboardUsers[0].points : 0; const topScore = leaderboardUsers.length > 0 ? leaderboardUsers[0].parsedPoints : 0;
// Find current user's points and rank - BUT don't filter by points > 0 for the current user // Find current user's points and rank - BUT don't filter by points > 0 for the current user
let yourPoints = 0; let yourPoints = 0;
let yourRank = null; let yourRank = null;
if (isAuthenticated && currentUserId) { if (isAuthenticated && currentUserId) {
// Look for the current user in ALL users, not just those with points > 0 // Look for the current user in ALL processed users, not just those with points > 0
const currentUser = response.items.find((user: any) => user.id === currentUserId); const currentUser = processedUsers.find(user => user.id === currentUserId);
if (currentUser) { if (currentUser) {
yourPoints = currentUser.points || 0; yourPoints = currentUser.parsedPoints || 0;
// Only calculate rank if user has points // Only calculate rank if user has points
if (yourPoints > 0) { if (yourPoints > 0) {
@ -119,15 +137,15 @@ export default function LeaderboardStats() {
}; };
fetchStats(); fetchStats();
}, [isAuthenticated, currentUserId]); }, [get, isAuthenticated, currentUserId]);
if (loading) { if (loading) {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => ( {[1, 2, 3, 4].map((i) => (
<div key={i} className="h-24 bg-base-200 dark:bg-gray-800/50 animate-pulse rounded-xl"> <div key={i} className="h-24 bg-gray-100/50 dark:bg-gray-800/50 animate-pulse rounded-xl">
<div className="h-4 w-24 bg-base-300 dark:bg-gray-700 rounded mb-2 mt-4 mx-4"></div> <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded mb-2 mt-4 mx-4"></div>
<div className="h-8 w-16 bg-base-300 dark:bg-gray-700 rounded mx-4"></div> <div className="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded mx-4"></div>
</div> </div>
))} ))}
</div> </div>
@ -136,27 +154,27 @@ export default function LeaderboardStats() {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="p-6 bg-base-100 dark:bg-gray-800/90 rounded-xl shadow-sm border border-base-200 dark:border-gray-700"> <div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Total Members</div> <div className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Members</div>
<div className="mt-2 text-3xl font-bold text-gray-800 dark:text-white">{stats.totalUsers}</div> <div className="mt-2 text-3xl font-bold text-gray-800 dark:text-white">{stats.totalUsers}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">In the leaderboard</div> <div className="mt-1 text-xs text-gray-500 dark:text-gray-400">In the leaderboard</div>
</div> </div>
<div className="p-6 bg-base-100 dark:bg-gray-800/90 rounded-xl shadow-sm border border-base-200 dark:border-gray-700"> <div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Total Points</div> <div className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Points</div>
<div className="mt-2 text-3xl font-bold text-gray-800 dark:text-white">{stats.totalPoints}</div> <div className="mt-2 text-3xl font-bold text-gray-800 dark:text-white">{stats.totalPoints}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Earned by all members</div> <div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Earned by all members</div>
</div> </div>
<div className="p-6 bg-base-100 dark:bg-gray-800/90 rounded-xl shadow-sm border border-base-200 dark:border-gray-700"> <div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Top Score</div> <div className="text-sm font-medium text-gray-600 dark:text-gray-300">Top Score</div>
<div className="mt-2 text-3xl font-bold text-primary dark:text-primary">{stats.topScore}</div> <div className="mt-2 text-3xl font-bold text-indigo-600 dark:text-indigo-400">{stats.topScore}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Highest individual points</div> <div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Highest individual points</div>
</div> </div>
<div className="p-6 bg-base-100 dark:bg-gray-800/90 rounded-xl shadow-sm border border-base-200 dark:border-gray-700"> <div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Your Score</div> <div className="text-sm font-medium text-gray-600 dark:text-gray-300">Your Score</div>
<div className="mt-2 text-3xl font-bold text-primary dark:text-primary"> <div className="mt-2 text-3xl font-bold text-indigo-600 dark:text-indigo-400">
{isAuthenticated ? stats.yourPoints : '-'} {isAuthenticated ? stats.yourPoints : '-'}
</div> </div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400"> <div className="mt-1 text-xs text-gray-500 dark:text-gray-400">

View file

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get'; import { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Authentication } from '../../../scripts/pocketbase/Authentication';
import type { User } from '../../../schemas/pocketbase/schema'; import type { User, LimitedUser } from '../../../schemas/pocketbase/schema';
import { Collections } from '../../../schemas/pocketbase/schema';
interface LeaderboardUser { interface LeaderboardUser {
id: string; id: string;
@ -63,21 +64,44 @@ export default function LeaderboardTable() {
setLoading(true); setLoading(true);
// Fetch users without sorting - we'll sort on client side // Fetch users without sorting - we'll sort on client side
const response = await get.getList('limitedUser', 1, 100, '', '', { const response = await get.getList(Collections.LIMITED_USERS, 1, 100, '', '', {
fields: ['id', 'name', 'points', 'avatar', 'major'] fields: ['id', 'name', 'points', 'avatar', 'major']
}); });
// First get the current user separately so we can include them even if they have 0 points // First get the current user separately so we can include them even if they have 0 points
let currentUserData = null; let currentUserData = null;
if (isAuthenticated && currentUserId) { if (isAuthenticated && currentUserId) {
currentUserData = response.items.find((user: Partial<User>) => user.id === currentUserId); currentUserData = response.items.find((user: Partial<LimitedUser>) => user.id === currentUserId);
} }
// Parse points from JSON string and convert to number
const processedUsers = response.items.map((user: any) => {
let pointsValue = 0;
try {
if (user.points) {
// Parse the JSON string to get the points value
const pointsData = JSON.parse(user.points);
pointsValue = typeof pointsData === 'number' ? pointsData : 0;
}
} catch (e) {
console.error('Error parsing points data:', e);
}
return {
id: user.id,
name: user.name,
major: user.major,
avatar: user.avatar, // Include avatar if it exists
points: user.points,
parsedPoints: pointsValue
};
});
// Filter and map to our leaderboard user format, and sort client-side // Filter and map to our leaderboard user format, and sort client-side
let leaderboardUsers = response.items let leaderboardUsers = processedUsers
.filter((user: Partial<User>) => user.points !== undefined && user.points !== null && user.points > 0) .filter(user => user.parsedPoints > 0)
.sort((a: Partial<User>, b: Partial<User>) => (b.points || 0) - (a.points || 0)) .sort((a, b) => (b.parsedPoints || 0) - (a.parsedPoints || 0))
.map((user: Partial<User>, index: number) => { .map((user, index: number) => {
// Check if this is the current user // Check if this is the current user
if (isAuthenticated && user.id === currentUserId) { if (isAuthenticated && user.id === currentUserId) {
setCurrentUserRank(index + 1); setCurrentUserRank(index + 1);
@ -86,7 +110,7 @@ export default function LeaderboardTable() {
return { return {
id: user.id || '', id: user.id || '',
name: user.name || 'Anonymous User', name: user.name || 'Anonymous User',
points: user.points || 0, points: user.parsedPoints,
avatar: user.avatar, avatar: user.avatar,
major: user.major major: user.major
}; };
@ -94,17 +118,21 @@ export default function LeaderboardTable() {
// Include current user even if they have 0 points, // Include current user even if they have 0 points,
// but don't include in ranking if they have no points // but don't include in ranking if they have no points
if (isAuthenticated && currentUserData && if (isAuthenticated && currentUserId) {
!leaderboardUsers.some(user => user.id === currentUserId)) { // Find current user in processed users
// User isn't already in the list (has 0 points) const currentUserProcessed = processedUsers.find(user => user.id === currentUserId);
// If current user exists and isn't already in the leaderboard (has 0 points)
if (currentUserProcessed && !leaderboardUsers.some(user => user.id === currentUserId)) {
leaderboardUsers.push({ leaderboardUsers.push({
id: currentUserData.id || '', id: currentUserProcessed.id || '',
name: currentUserData.name || 'Anonymous User', name: currentUserProcessed.name || 'Anonymous User',
points: currentUserData.points || 0, points: currentUserProcessed.parsedPoints || 0,
avatar: currentUserData.avatar, avatar: currentUserProcessed.avatar,
major: currentUserData.major major: currentUserProcessed.major
}); });
} }
}
setUsers(leaderboardUsers); setUsers(leaderboardUsers);
setFilteredUsers(leaderboardUsers); setFilteredUsers(leaderboardUsers);
@ -117,7 +145,7 @@ export default function LeaderboardTable() {
}; };
fetchLeaderboard(); fetchLeaderboard();
}, [isAuthenticated, currentUserId]); }, [get, isAuthenticated, currentUserId]);
useEffect(() => { useEffect(() => {
if (searchQuery.trim() === '') { if (searchQuery.trim() === '') {
@ -184,37 +212,37 @@ export default function LeaderboardTable() {
<input <input
type="text" type="text"
placeholder="Search by name or major..." placeholder="Search by name or major..."
className="w-full pl-10 pr-4 py-2 border border-base-300 dark:border-gray-700 rounded-lg className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg
bg-base-100 dark:bg-gray-800/90 text-gray-700 dark:text-gray-200 focus:outline-none 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 shadow-sm" focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
</div> </div>
{/* Leaderboard table */} {/* Leaderboard table */}
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm"> <div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-base-200 dark:bg-gray-800/80"> <thead className="bg-gray-50/80 dark:bg-gray-800/80">
<tr> <tr>
<th scope="col" className="w-16 px-6 py-3 text-center text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider"> <th scope="col" className="w-16 px-6 py-3 text-center text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
Rank Rank
</th> </th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider"> <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
User User
</th> </th>
<th scope="col" className="w-24 px-6 py-3 text-center text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider"> <th scope="col" className="w-24 px-6 py-3 text-center text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
Points Points
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-base-100 dark:bg-gray-900/90 divide-y divide-gray-200 dark:divide-gray-800"> <tbody className="bg-white/90 dark:bg-gray-900/90 divide-y divide-gray-200 dark:divide-gray-800">
{currentUsers.map((user, index) => { {currentUsers.map((user, index) => {
const actualRank = user.points > 0 ? indexOfFirstUser + index + 1 : null; const actualRank = user.points > 0 ? indexOfFirstUser + index + 1 : null;
const isCurrentUser = user.id === currentUserId; const isCurrentUser = user.id === currentUserId;
return ( return (
<tr key={user.id} className={isCurrentUser ? 'bg-primary/10 dark:bg-primary/20' : ''}> <tr key={user.id} className={isCurrentUser ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}>
<td className="px-6 py-4 whitespace-nowrap text-center"> <td className="px-6 py-4 whitespace-nowrap text-center">
{actualRank ? ( {actualRank ? (
actualRank <= 3 ? ( actualRank <= 3 ? (
@ -233,7 +261,7 @@ export default function LeaderboardTable() {
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10"> <div className="flex-shrink-0 h-10 w-10">
<div className="w-10 h-10 rounded-full bg-base-300 dark:bg-gray-700 flex items-center justify-center overflow-hidden relative"> <div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden relative">
{user.avatar ? ( {user.avatar ? (
<img className="h-10 w-10 rounded-full" src={user.avatar} alt={user.name} /> <img className="h-10 w-10 rounded-full" src={user.avatar} alt={user.name} />
) : ( ) : (
@ -255,7 +283,7 @@ export default function LeaderboardTable() {
</div> </div>
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-center font-bold text-primary dark:text-primary"> <td className="px-6 py-4 whitespace-nowrap text-center font-bold text-indigo-600 dark:text-indigo-400">
{user.points} {user.points}
</td> </td>
</tr> </tr>
@ -270,8 +298,8 @@ export default function LeaderboardTable() {
<div className="flex justify-center mt-6"> <div className="flex justify-center mt-6">
<nav className="flex items-center"> <nav className="flex items-center">
<button <button
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-base-300 dark:border-gray-700 className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 dark:border-gray-700
bg-base-100 dark:bg-gray-800/90 text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-base-200 dark:hover:bg-gray-700 shadow-sm" bg-white/90 dark:bg-gray-800/90 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => paginate(Math.max(1, currentPage - 1))} onClick={() => paginate(Math.max(1, currentPage - 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
> >
@ -284,11 +312,11 @@ export default function LeaderboardTable() {
{Array.from({ length: totalPages }, (_, i) => ( {Array.from({ length: totalPages }, (_, i) => (
<button <button
key={i + 1} key={i + 1}
className={`relative inline-flex items-center px-4 py-2 border border-base-300 dark:border-gray-700 className={`relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-700
bg-base-100 dark:bg-gray-800/90 text-sm font-medium ${currentPage === i + 1 bg-white/90 dark:bg-gray-800/90 text-sm font-medium ${currentPage === i + 1
? 'text-primary dark:text-primary border-primary dark:border-primary z-10 font-bold' ? 'text-indigo-600 dark:text-indigo-400 border-indigo-500 dark:border-indigo-500 z-10'
: 'text-gray-700 dark:text-gray-300 hover:bg-base-200 dark:hover:bg-gray-700' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
} shadow-sm`} }`}
onClick={() => paginate(i + 1)} onClick={() => paginate(i + 1)}
> >
{i + 1} {i + 1}
@ -296,8 +324,8 @@ export default function LeaderboardTable() {
))} ))}
<button <button
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-base-300 dark:border-gray-700 className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 dark:border-gray-700
bg-base-100 dark:bg-gray-800/90 text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-base-200 dark:hover:bg-gray-700 shadow-sm" bg-white/90 dark:bg-gray-800/90 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => paginate(Math.min(totalPages, currentPage + 1))} onClick={() => paginate(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
> >
@ -312,9 +340,9 @@ export default function LeaderboardTable() {
{/* Show current user rank if not in current page */} {/* Show current user rank if not in current page */}
{isAuthenticated && currentUserRank && !currentUsers.some(user => user.id === currentUserId) && ( {isAuthenticated && currentUserRank && !currentUsers.some(user => user.id === currentUserId) && (
<div className="mt-4 p-3 bg-base-200 dark:bg-gray-800/80 border border-base-300 dark:border-gray-700 rounded-lg shadow-sm"> <div className="mt-4 p-3 bg-gray-50/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 rounded-lg">
<p className="text-center text-sm text-gray-700 dark:text-gray-300"> <p className="text-center text-sm text-gray-700 dark:text-gray-300">
Your rank: <span className="font-bold text-primary dark:text-primary">#{currentUserRank}</span> Your rank: <span className="font-bold text-indigo-600 dark:text-indigo-400">#{currentUserRank}</span>
</p> </p>
</div> </div>
)} )}
@ -323,7 +351,7 @@ export default function LeaderboardTable() {
{isAuthenticated && currentUserId && {isAuthenticated && currentUserId &&
!currentUserRank && !currentUserRank &&
currentUsers.some(user => user.id === currentUserId) && ( currentUsers.some(user => user.id === currentUserId) && (
<div className="mt-4 p-3 bg-base-200 dark:bg-gray-800/80 border border-base-300 dark:border-gray-700 rounded-lg shadow-sm"> <div className="mt-4 p-3 bg-gray-50/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 rounded-lg">
<p className="text-center text-sm text-gray-700 dark:text-gray-300"> <p className="text-center text-sm text-gray-700 dark:text-gray-300">
Participate in events to earn points and get ranked! Participate in events to earn points and get ranked!
</p> </p>

View file

@ -0,0 +1,11 @@
---
import OfficerManagementComponent from "./OfficerManagement/OfficerManagement";
---
<section>
<h2 class="text-2xl font-bold mb-6">Officer Management</h2>
<div class="bg-gray-800 shadow-md rounded-lg p-6">
<OfficerManagementComponent client:load />
</div>
</section>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,89 @@
---
import { Icon } from "astro-icon/components";
import EmailRequestSettings from "./SettingsSection/EmailRequestSettings";
// Import environment variables for debugging if needed
const logtoApiEndpoint = import.meta.env.LOGTO_API_ENDPOINT || "";
---
<div id="officer-email-section" class="">
<div class="mb-6">
<h2 class="text-2xl font-bold">IEEE Email Management</h2>
<p class="opacity-70">Manage your official IEEE UCSD email address</p>
</div>
<!-- IEEE Email Management Card -->
<div
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
>
<div class="card-body">
<h3 class="card-title flex items-center gap-3">
<div
class="inline-flex items-center justify-center p-3 rounded-full bg-primary text-primary-foreground"
>
<Icon name="heroicons:envelope" class="h-5 w-5" />
</div>
IEEE Email Address
</h3>
<p class="text-sm opacity-70 mb-4">
Request and manage your official IEEE UCSD email address. This email can be used for official IEEE communications and professional purposes.
</p>
<div class="h-px w-full bg-border my-4"></div>
<EmailRequestSettings client:load />
</div>
</div>
<!-- Email Guidelines Card -->
<div
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
>
<div class="card-body">
<h3 class="card-title flex items-center gap-3">
<div
class="inline-flex items-center justify-center p-3 rounded-full bg-info text-info-foreground"
>
<Icon name="heroicons:information-circle" class="h-5 w-5" />
</div>
Email Usage Guidelines
</h3>
<div class="space-y-4 text-sm">
<div class="alert alert-info">
<Icon name="heroicons:information-circle" class="h-4 w-4" />
<div>
<h4 class="font-bold">Officer Email Access</h4>
<p>IEEE email addresses are only available to active IEEE UCSD officers. Your officer status is automatically verified when you request an email.</p>
</div>
</div>
<div class="space-y-2">
<h4 class="font-semibold">Acceptable Use:</h4>
<ul class="list-disc list-inside space-y-1 opacity-80">
<li>Official IEEE UCSD communications</li>
<li>Professional networking related to IEEE activities</li>
<li>Event coordination and planning</li>
<li>Communications with sponsors and external partners</li>
</ul>
</div>
<div class="space-y-2">
<h4 class="font-semibold">Email Features:</h4>
<ul class="list-disc list-inside space-y-1 opacity-80">
<li>Webmail access at <a href="https://mail.ieeeucsd.org" target="_blank" rel="noopener noreferrer" class="link link-primary">https://mail.ieeeucsd.org</a></li>
<li>IMAP/SMTP support for email clients</li>
<li>5GB storage space</li>
<li>Professional @ieeeucsd.org domain</li>
</ul>
</div>
<div class="space-y-2">
<h4 class="font-semibold">Important Notes:</h4>
<ul class="list-disc list-inside space-y-1 opacity-80">
<li>Your email username is based on your personal email address</li>
<li>Passwords can be reset through this interface</li>
<li>Email access may be revoked when officer status changes</li>
<li>Contact the webmaster for any technical issues</li>
</ul>
</div>
</div>
</div>
</div>
</div>

View file

@ -891,27 +891,27 @@ const currentPage = eventResponse.page;
let start: Date, end: Date; let start: Date, end: Date;
// Determine quarter (0-based months: 0-11) // Determine quarter (0-based months: 0-11)
// Q1: Sept-Dec (8-11) // Fall: Sept-Dec (8-11)
// Q2: Jan-Mar (0-2) // Winter: Jan-Mar (0-2)
// Q3: Mar-Jun (2-5) // Spring: Apr-Jun (3-5)
// Q4: Jun-Sept (5-8) // Summer: Jul-Sept (6-8)
if (month >= 8) { if (month >= 8) {
// Q1: Sept-Dec // Fall: Sept-Dec
start = new Date(year, 8, 1); start = new Date(year, 8, 1);
end = new Date(year, 11, 31); end = new Date(year, 11, 31);
} else if (month < 2) { } else if (month >= 0 && month < 3) {
// Q2: Jan-Mar // Winter: Jan-Mar
start = new Date(year, 0, 1); start = new Date(year, 0, 1);
end = new Date(year, 2, 31); end = new Date(year, 2, 31);
} else if (month < 5) { } else if (month >= 3 && month < 6) {
// Q3: Mar-Jun // Spring: Apr-Jun
start = new Date(year, 2, 1); start = new Date(year, 3, 1);
end = new Date(year, 5, 30); end = new Date(year, 5, 30);
} else { } else {
// Q4: Jun-Sept // Summer: Jul-Sept
start = new Date(year, 5, 1); start = new Date(year, 6, 1);
end = new Date(year, 8, 0); // End on Aug 31 end = new Date(year, 8, 30);
} }
return { start, end }; return { start, end };
@ -924,14 +924,14 @@ const currentPage = eventResponse.page;
if (month >= 8) { if (month >= 8) {
// Sept-Dec // Sept-Dec
return "Fall"; return "Fall";
} else if (month < 2) { } else if (month >= 0 && month < 3) {
// Jan-Mar // Jan-Mar
return "Winter"; return "Winter";
} else if (month < 5) { } else if (month >= 3 && month < 6) {
// Mar-Jun // Apr-Jun
return "Spring"; return "Spring";
} else { } else {
// Jun-Sept // Jul-Sept
return "Summer"; return "Summer";
} }
} }
@ -979,13 +979,13 @@ const currentPage = eventResponse.page;
isInQuarter = month >= 8 && month <= 11; // Sept-Dec isInQuarter = month >= 8 && month <= 11; // Sept-Dec
break; break;
case "winter": case "winter":
isInQuarter = month >= 0 && month <= 2; // Jan-Mar isInQuarter = month >= 0 && month < 3; // Jan-Mar (0-2)
break; break;
case "spring": case "spring":
isInQuarter = month >= 2 && month <= 5; // Mar-Jun isInQuarter = month >= 3 && month < 6; // Apr-Jun (3-5)
break; break;
case "summer": case "summer":
isInQuarter = month >= 5 && month <= 8; // Jun-Sept isInQuarter = month >= 6 && month < 9; // Jul-Sept (6-8)
break; break;
} }
if (isInQuarter) { if (isInQuarter) {

View file

@ -5,6 +5,7 @@ import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { Update } from "../../../scripts/pocketbase/Update"; import { Update } from "../../../scripts/pocketbase/Update";
import { FileManager } from "../../../scripts/pocketbase/FileManager"; import { FileManager } from "../../../scripts/pocketbase/FileManager";
import { SendLog } from "../../../scripts/pocketbase/SendLog"; import { SendLog } from "../../../scripts/pocketbase/SendLog";
import { Realtime } from "../../../scripts/pocketbase/Realtime";
import FilePreview from "../universal/FilePreview"; import FilePreview from "../universal/FilePreview";
import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase"; import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase";
import { DataSyncService } from '../../../scripts/database/DataSyncService'; import { DataSyncService } from '../../../scripts/database/DataSyncService';
@ -132,6 +133,28 @@ const EventForm = memo(({
/> />
</div> </div>
{/* Event Type */}
<div className="form-control">
<label className="label">
<span className="label-text">Event Type</span>
<span className="label-text-alt text-error">*</span>
</label>
<select
name="editEventType"
className="select select-bordered"
value={event?.event_type || "other"}
onChange={(e) => handleChange('event_type', e.target.value)}
required
>
<option value="social">Social</option>
<option value="technical">Technical</option>
<option value="outreach">Outreach</option>
<option value="professional">Professional</option>
<option value="workshop">Projects</option>
<option value="other">Other</option>
</select>
</div>
{/* Points to Reward */} {/* Points to Reward */}
<div className="form-control"> <div className="form-control">
<label className="label"> <label className="label">
@ -142,7 +165,7 @@ const EventForm = memo(({
type="number" type="number"
name="editEventPoints" name="editEventPoints"
className="input input-bordered" className="input input-bordered"
value={event?.points_to_reward || 0} value={event?.points_to_reward || ""}
onChange={(e) => handleChange('points_to_reward', Number(e.target.value))} onChange={(e) => handleChange('points_to_reward', Number(e.target.value))}
min="0" min="0"
required required
@ -240,7 +263,15 @@ const EventForm = memo(({
// Show error for rejected files // Show error for rejected files
if (rejectedFiles.length > 0) { if (rejectedFiles.length > 0) {
const errorMessage = `The following files were not added:\n${rejectedFiles.map(f => `${f.name}: ${f.reason}`).join('\n')}`; const errorMessage = `The following files were not added:\n${rejectedFiles.map(f => `${f.name}: ${f.reason}`).join('\n')}`;
toast.error(errorMessage); // Use toast with custom styling to ensure visibility above modal
toast.error(errorMessage, {
duration: 5000,
style: {
zIndex: 9999, // Ensure it's above the modal
maxWidth: '500px',
whiteSpace: 'pre-line' // Preserve line breaks
}
});
} }
setSelectedFiles(newFiles); setSelectedFiles(newFiles);
@ -293,6 +324,31 @@ const EventForm = memo(({
> >
<Icon icon="heroicons:eye" className="h-4 w-4" /> <Icon icon="heroicons:eye" className="h-4 w-4" />
</button> </button>
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={async () => {
if (event?.id) {
try {
// Get file URL with token for protected files
const url = await fileManager.getFileUrlWithToken(
"events",
event.id,
filename,
true
);
// Open file in new tab
window.open(url, '_blank');
} catch (error) {
console.error("Failed to open file:", error);
toast.error("Failed to open file. Please try again.");
}
}
}}
>
<Icon icon="heroicons:arrow-top-right-on-square" className="h-4 w-4" />
</button>
<div className="text-error"> <div className="text-error">
{filesToDelete.has(filename) ? ( {filesToDelete.has(filename) ? (
<button <button
@ -401,6 +457,7 @@ interface EventChanges {
end_date?: string; end_date?: string;
published?: boolean; published?: boolean;
has_food?: boolean; has_food?: boolean;
event_type?: string;
} }
interface FileChanges { interface FileChanges {
@ -487,7 +544,8 @@ class ChangeTracker {
'start_date', 'start_date',
'end_date', 'end_date',
'published', 'published',
'has_food' 'has_food',
'event_type'
]; ];
for (const field of fields) { for (const field of fields) {
@ -550,11 +608,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
event_code: "", event_code: "",
location: "", location: "",
files: [], files: [],
points_to_reward: 0, points_to_reward: null as unknown as number,
start_date: "", start_date: "",
end_date: "", end_date: "",
published: false, published: false,
has_food: false has_food: false,
event_type: "other"
}); });
const [previewUrl, setPreviewUrl] = useState(""); const [previewUrl, setPreviewUrl] = useState("");
@ -571,7 +630,8 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
auth: Authentication.getInstance(), auth: Authentication.getInstance(),
update: Update.getInstance(), update: Update.getInstance(),
fileManager: FileManager.getInstance(), fileManager: FileManager.getInstance(),
sendLog: SendLog.getInstance() sendLog: SendLog.getInstance(),
realtime: Realtime.getInstance()
}), []); }), []);
// Handle field changes // Handle field changes
@ -590,17 +650,35 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
const initializeEventData = useCallback(async (eventId: string) => { const initializeEventData = useCallback(async (eventId: string) => {
try { try {
if (eventId) { if (eventId) {
// Show loading state
setIsSubmitting(true);
// Clear cache to ensure fresh data // Clear cache to ensure fresh data
const dataSync = DataSyncService.getInstance(); const dataSync = DataSyncService.getInstance();
await dataSync.clearCache(); await dataSync.clearCache();
// Fetch fresh event data // Fetch fresh event data with expanded relations if needed
const eventData = await services.get.getOne<Event>(Collections.EVENTS, eventId); const eventData = await services.get.getOne<Event>(
Collections.EVENTS,
eventId,
{
disableAutoCancellation: true,
// Add any fields to expand if needed
// expand: ['related_field1', 'related_field2']
}
);
if (!eventData) { if (!eventData) {
throw new Error("Event not found"); throw new Error("Event not found");
} }
// Log successful data fetch
await services.sendLog.send(
"view",
"event",
`Loaded event data: ${eventData.event_name} (${eventId})`
);
// Ensure dates are properly formatted for datetime-local input // Ensure dates are properly formatted for datetime-local input
if (eventData.start_date) { if (eventData.start_date) {
// Convert to Date object first to ensure proper formatting // Convert to Date object first to ensure proper formatting
@ -624,15 +702,44 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
event_code: eventData.event_code || '', event_code: eventData.event_code || '',
location: eventData.location || '', location: eventData.location || '',
files: eventData.files || [], files: eventData.files || [],
points_to_reward: eventData.points_to_reward || 0, points_to_reward: eventData.points_to_reward || null as unknown as number,
start_date: eventData.start_date || '', start_date: eventData.start_date || '',
end_date: eventData.end_date || '', end_date: eventData.end_date || '',
published: eventData.published || false, published: eventData.published || false,
has_food: eventData.has_food || false has_food: eventData.has_food || false,
event_type: eventData.event_type || 'other'
}); });
// Set up realtime subscription for this event
const realtime = services.realtime;
// Define the RealtimeEvent type for proper typing
interface RealtimeEvent<T> {
action: "create" | "update" | "delete";
record: T;
}
const subscriptionId = realtime.subscribeToRecord<RealtimeEvent<Event>>(
Collections.EVENTS,
eventId,
(data) => {
if (data.action === "update") {
// Auto-refresh data when event is updated elsewhere
initializeEventData(eventId);
toast.success("Event data has been updated");
}
}
);
// Store subscription ID for cleanup
(window as any).eventSubscriptionId = subscriptionId;
// console.log("Event data loaded successfully:", eventData); // console.log("Event data loaded successfully:", eventData);
} else { } else {
// Creating a new event
const now = new Date();
const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
setEvent({ setEvent({
id: '', id: '',
created: '', created: '',
@ -642,11 +749,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
event_code: '', event_code: '',
location: '', location: '',
files: [], files: [],
points_to_reward: 0, points_to_reward: null as unknown as number,
start_date: '', start_date: Get.formatLocalDate(now, false),
end_date: '', end_date: Get.formatLocalDate(oneHourLater, false),
published: false, published: false,
has_food: false has_food: false,
event_type: "other"
}); });
} }
setSelectedFiles(new Map()); setSelectedFiles(new Map());
@ -656,8 +764,10 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
} catch (error) { } catch (error) {
console.error("Failed to initialize event data:", error); console.error("Failed to initialize event data:", error);
toast.error("Failed to load event data. Please try again."); toast.error("Failed to load event data. Please try again.");
} finally {
setIsSubmitting(false);
} }
}, [services.get]); }, [services]);
// Expose initializeEventData to window // Expose initializeEventData to window
useEffect(() => { useEffect(() => {
@ -698,6 +808,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
if (!confirmed) return; if (!confirmed) return;
} }
// Clean up realtime subscription if it exists
if ((window as any).eventSubscriptionId) {
services.realtime.unsubscribe((window as any).eventSubscriptionId);
delete (window as any).eventSubscriptionId;
}
setEvent({ setEvent({
id: "", id: "",
created: "", created: "",
@ -707,11 +823,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
event_code: "", event_code: "",
location: "", location: "",
files: [], files: [],
points_to_reward: 0, points_to_reward: null as unknown as number,
start_date: "", start_date: "",
end_date: "", end_date: "",
published: false, published: false,
has_food: false has_food: false,
event_type: "other"
}); });
setSelectedFiles(new Map()); setSelectedFiles(new Map());
setFilesToDelete(new Set()); setFilesToDelete(new Set());
@ -719,12 +836,24 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
setPreviewUrl(""); setPreviewUrl("");
setPreviewFilename(""); setPreviewFilename("");
// Clear file input element to reset filename display
const fileInput = document.querySelector('input[name="editEventFiles"]') as HTMLInputElement;
if (fileInput) {
fileInput.value = "";
}
const modal = document.getElementById("editEventModal") as HTMLDialogElement; const modal = document.getElementById("editEventModal") as HTMLDialogElement;
if (modal) modal.close(); if (modal) modal.close();
}, [hasUnsavedChanges, isSubmitting]); }, [hasUnsavedChanges, isSubmitting, services.realtime]);
// Function to close modal after saving (without confirmation) // Function to close modal after saving (without confirmation)
const closeModalAfterSave = useCallback(() => { const closeModalAfterSave = useCallback(() => {
// Clean up realtime subscription if it exists
if ((window as any).eventSubscriptionId) {
services.realtime.unsubscribe((window as any).eventSubscriptionId);
delete (window as any).eventSubscriptionId;
}
setEvent({ setEvent({
id: "", id: "",
created: "", created: "",
@ -734,11 +863,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
event_code: "", event_code: "",
location: "", location: "",
files: [], files: [],
points_to_reward: 0, points_to_reward: null as unknown as number,
start_date: "", start_date: "",
end_date: "", end_date: "",
published: false, published: false,
has_food: false has_food: false,
event_type: "other"
}); });
setSelectedFiles(new Map()); setSelectedFiles(new Map());
setFilesToDelete(new Set()); setFilesToDelete(new Set());
@ -746,9 +876,15 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
setPreviewUrl(""); setPreviewUrl("");
setPreviewFilename(""); setPreviewFilename("");
// Reset the file input element to clear the filename display
const fileInput = document.querySelector('input[name="editEventFiles"]') as HTMLInputElement;
if (fileInput) {
fileInput.value = "";
}
const modal = document.getElementById("editEventModal") as HTMLDialogElement; const modal = document.getElementById("editEventModal") as HTMLDialogElement;
if (modal) modal.close(); if (modal) modal.close();
}, []); }, [services.realtime]);
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@ -778,11 +914,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
event_code: formData.get("editEventCode") as string, event_code: formData.get("editEventCode") as string,
location: formData.get("editEventLocation") as string, location: formData.get("editEventLocation") as string,
files: event.files || [], files: event.files || [],
points_to_reward: parseInt(formData.get("editEventPoints") as string) || 0, points_to_reward: formData.get("editEventPoints") ? parseInt(formData.get("editEventPoints") as string) : null as unknown as number,
start_date: formData.get("editEventStartDate") as string, start_date: formData.get("editEventStartDate") as string,
end_date: formData.get("editEventEndDate") as string, end_date: formData.get("editEventEndDate") as string,
published: formData.get("editEventPublished") === "on", published: formData.get("editEventPublished") === "on",
has_food: formData.get("editEventHasFood") === "on" has_food: formData.get("editEventHasFood") === "on",
event_type: formData.get("editEventType") as string || "other"
}; };
// Log the update attempt // Log the update attempt

View file

@ -70,6 +70,12 @@ interface ASFundingSectionProps {
} }
const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataChange }) => { const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataChange }) => {
// Check initial budget status
React.useEffect(() => {
if (formData.invoiceData?.total) {
checkBudgetLimit(formData.invoiceData.total);
}
}, [formData.expected_attendance]);
const [invoiceFiles, setInvoiceFiles] = useState<File[]>(formData.invoice_files || []); const [invoiceFiles, setInvoiceFiles] = useState<File[]>(formData.invoice_files || []);
const [jsonInput, setJsonInput] = useState<string>(''); const [jsonInput, setJsonInput] = useState<string>('');
const [jsonError, setJsonError] = useState<string>(''); const [jsonError, setJsonError] = useState<string>('');
@ -80,11 +86,26 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
const handleInvoiceFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInvoiceFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
const newFiles = Array.from(e.target.files) as File[]; const newFiles = Array.from(e.target.files) as File[];
setInvoiceFiles(newFiles); // Combine existing files with new files instead of replacing
onDataChange({ invoice_files: newFiles }); const combinedFiles = [...invoiceFiles, ...newFiles];
setInvoiceFiles(combinedFiles);
onDataChange({ invoice_files: combinedFiles });
} }
}; };
// Handle removing individual files
const handleRemoveFile = (indexToRemove: number) => {
const updatedFiles = invoiceFiles.filter((_, index) => index !== indexToRemove);
setInvoiceFiles(updatedFiles);
onDataChange({ invoice_files: updatedFiles });
};
// Handle clearing all files
const handleClearAllFiles = () => {
setInvoiceFiles([]);
onDataChange({ invoice_files: [] });
};
// Handle JSON input change // Handle JSON input change
const handleJsonInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleJsonInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setJsonInput(e.target.value); setJsonInput(e.target.value);
@ -122,6 +143,19 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
}; };
// Validate and apply JSON // Validate and apply JSON
// Check budget limits and show warning if exceeded
const checkBudgetLimit = (total: number) => {
const maxBudget = Math.min(formData.expected_attendance * 10, 5000);
if (total > maxBudget) {
toast.error(`Total amount ($${total.toFixed(2)}) exceeds maximum funding of $${maxBudget.toFixed(2)} for ${formData.expected_attendance} attendees.`, {
duration: 4000,
position: 'top-center'
});
return true;
}
return false;
};
const validateAndApplyJson = () => { const validateAndApplyJson = () => {
try { try {
if (!jsonInput.trim()) { if (!jsonInput.trim()) {
@ -181,6 +215,9 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
total: data.total total: data.total
}, null, 2); }, null, 2);
// Check budget limits and show toast if needed
checkBudgetLimit(data.total);
// Apply the JSON data to the form // Apply the JSON data to the form
onDataChange({ onDataChange({
invoiceData: data, invoiceData: data,
@ -212,15 +249,17 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const newFiles = Array.from(e.dataTransfer.files) as File[]; const newFiles = Array.from(e.dataTransfer.files) as File[];
setInvoiceFiles(newFiles); // Combine existing files with new files instead of replacing
onDataChange({ invoice_files: newFiles }); const combinedFiles = [...invoiceFiles, ...newFiles];
setInvoiceFiles(combinedFiles);
onDataChange({ invoice_files: combinedFiles });
} }
}; };
// Handle invoice data change from the invoice builder // Handle invoice data change from the invoice builder
const handleInvoiceDataChange = (data: InvoiceData) => { const handleInvoiceDataChange = (data: InvoiceData) => {
// Calculate if budget exceeds maximum allowed // Check budget limits and show toast if needed
const maxBudget = Math.min(formData.expected_attendance * 10, 5000); checkBudgetLimit(data.total);
onDataChange({ onDataChange({
invoiceData: data, invoiceData: data,
@ -289,20 +328,44 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
{invoiceFiles.length > 0 ? ( {invoiceFiles.length > 0 ? (
<> <>
<div className="flex items-center justify-between w-full">
<p className="font-medium text-primary">{invoiceFiles.length} file(s) selected:</p> <p className="font-medium text-primary">{invoiceFiles.length} file(s) selected:</p>
<div className="max-h-24 overflow-y-auto text-left w-full"> <button
<ul className="list-disc list-inside text-sm"> type="button"
{invoiceFiles.map((file, index) => ( onClick={(e) => {
<li key={index} className="truncate">{file.name}</li> e.stopPropagation();
))} handleClearAllFiles();
</ul> }}
className="btn btn-xs btn-outline btn-error"
title="Clear all files"
>
Clear All
</button>
</div> </div>
<p className="text-xs text-gray-500">Click or drag to replace</p> <div className="max-h-32 overflow-y-auto text-left w-full space-y-1">
{invoiceFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-base-100 p-2 rounded">
<span className="text-sm truncate flex-1">{file.name}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveFile(index);
}}
className="btn btn-xs btn-error ml-2"
title="Remove file"
>
<Icon icon="heroicons:x-mark" className="w-3 h-3" />
</button>
</div>
))}
</div>
<p className="text-xs text-gray-500">Click or drag to add more files</p>
</> </>
) : ( ) : (
<> <>
<p className="font-medium">Drop your invoice files here or click to browse</p> <p className="font-medium">Drop your invoice files here or click to browse</p>
<p className="text-xs text-gray-500">Supports PDF, JPG, JPEG, PNG</p> <p className="text-xs text-gray-500">Supports PDF, JPG, JPEG, PNG (multiple files allowed)</p>
</> </>
)} )}
</div> </div>

View file

@ -129,7 +129,27 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
type="datetime-local" type="datetime-local"
className="input input-bordered focus:input-primary transition-all duration-300 mt-2" className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
value={formData.start_date_time} value={formData.start_date_time}
onChange={(e) => onDataChange({ start_date_time: e.target.value })} onChange={(e) => {
const newStartDateTime = e.target.value;
onDataChange({ start_date_time: newStartDateTime });
// If there's already an end time set, update it to use the new start date
if (formData.end_date_time && newStartDateTime) {
try {
const existingEndDate = new Date(formData.end_date_time);
const newStartDate = new Date(newStartDateTime);
if (!isNaN(existingEndDate.getTime()) && !isNaN(newStartDate.getTime())) {
// Keep the same time but update to the new date
const updatedEndDate = new Date(newStartDate);
updatedEndDate.setHours(existingEndDate.getHours(), existingEndDate.getMinutes(), 0, 0);
onDataChange({ end_date_time: updatedEndDate.toISOString() });
}
} catch (error) {
console.error('Error updating end date when start date changed:', error);
}
}
}}
required required
whileHover="hover" whileHover="hover"
variants={inputHoverVariants} variants={inputHoverVariants}
@ -155,25 +175,59 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
<motion.input <motion.input
type="time" type="time"
className="input input-bordered focus:input-primary transition-all duration-300" className="input input-bordered focus:input-primary transition-all duration-300"
value={formData.end_date_time ? new Date(formData.end_date_time).toTimeString().substring(0, 5) : ''} value={formData.end_date_time ? (() => {
try {
const endDate = new Date(formData.end_date_time);
if (isNaN(endDate.getTime())) return '';
return endDate.toTimeString().substring(0, 5);
} catch (e) {
return '';
}
})() : ''}
onChange={(e) => { onChange={(e) => {
if (formData.start_date_time) { const timeValue = e.target.value;
if (timeValue && formData.start_date_time) {
try {
// Create a new date object from start_date_time // Create a new date object from start_date_time
const startDate = new Date(formData.start_date_time); const startDate = new Date(formData.start_date_time);
if (isNaN(startDate.getTime())) {
console.error('Invalid start date time');
return;
}
// Parse the time value // Parse the time value
const [hours, minutes] = e.target.value.split(':').map(Number); const [hours, minutes] = timeValue.split(':').map(Number);
// Set the hours and minutes on the date
startDate.setHours(hours, minutes); // Validate hours and minutes
if (isNaN(hours) || isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
console.error('Invalid time values');
return;
}
// Create a new date with the same date as start but different time
const endDate = new Date(startDate);
endDate.setHours(hours, minutes, 0, 0);
// Update end_date_time with the new time but same date as start // Update end_date_time with the new time but same date as start
onDataChange({ end_date_time: startDate.toISOString() }); onDataChange({ end_date_time: endDate.toISOString() });
} catch (error) {
console.error('Error setting end time:', error);
}
} else if (!timeValue) {
// Clear end_date_time if time is cleared
onDataChange({ end_date_time: '' });
} }
}} }}
required required
disabled={!formData.start_date_time}
whileHover="hover" whileHover="hover"
variants={inputHoverVariants} variants={inputHoverVariants}
/> />
<p className="text-xs text-base-content/60"> <p className="text-xs text-base-content/60">
The end time will use the same date as the start date. {!formData.start_date_time
? "Please set the start date and time first."
: "The end time will use the same date as the start date."
}
</p> </p>
</div> </div>
</motion.div> </motion.div>

View file

@ -7,6 +7,7 @@ import { FileManager } from '../../../scripts/pocketbase/FileManager';
import { DataSyncService } from '../../../scripts/database/DataSyncService'; import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema'; import { Collections } from '../../../schemas/pocketbase/schema';
import { EventRequestStatus } from '../../../schemas/pocketbase'; import { EventRequestStatus } from '../../../schemas/pocketbase';
import { EmailClient } from '../../../scripts/email/EmailClient';
// Form sections // Form sections
import PRSection from './PRSection'; import PRSection from './PRSection';
@ -69,13 +70,13 @@ export interface EventRequestFormData {
flyer_advertising_start_date: string; flyer_advertising_start_date: string;
flyer_additional_requests: string; flyer_additional_requests: string;
required_logos: string[]; required_logos: string[];
other_logos: File[]; // Form uses File objects, schema uses strings other_logos: File[]; // Form uses File objects, schema uses strings - MULTIPLE FILES
advertising_format: string; advertising_format: string;
will_or_have_room_booking: boolean; will_or_have_room_booking: boolean;
expected_attendance: number; expected_attendance: number;
room_booking: File | null; room_booking_files: File[]; // CHANGED: Multiple room booking files instead of single
invoice: File | null; invoice: File | null;
invoice_files: File[]; invoice_files: File[]; // MULTIPLE FILES
invoiceData: InvoiceData; invoiceData: InvoiceData;
needs_graphics?: boolean | null; needs_graphics?: boolean | null;
needs_as_funding?: boolean | null; needs_as_funding?: boolean | null;
@ -88,7 +89,6 @@ import CustomAlert from '../universal/CustomAlert';
const EventRequestForm: React.FC = () => { const EventRequestForm: React.FC = () => {
const [currentStep, setCurrentStep] = useState<number>(1); const [currentStep, setCurrentStep] = useState<number>(1);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false); const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Initialize form data // Initialize form data
const [formData, setFormData] = useState<EventRequestFormData>({ const [formData, setFormData] = useState<EventRequestFormData>({
@ -108,7 +108,7 @@ const EventRequestForm: React.FC = () => {
advertising_format: '', advertising_format: '',
will_or_have_room_booking: false, will_or_have_room_booking: false,
expected_attendance: 0, expected_attendance: 0,
room_booking: null, room_booking_files: [],
as_funding_required: false, as_funding_required: false,
food_drinks_being_served: false, food_drinks_being_served: false,
itemized_invoice: '', itemized_invoice: '',
@ -134,9 +134,10 @@ const EventRequestForm: React.FC = () => {
const dataToStore = { const dataToStore = {
...formDataToSave, ...formDataToSave,
other_logos: [], other_logos: [],
room_booking: null, room_booking_files: [],
invoice: null, invoice: null,
invoice_files: [] invoice_files: [],
savedAt: Date.now() // Add timestamp for stale data detection
}; };
localStorage.setItem('eventRequestFormData', JSON.stringify(dataToStore)); localStorage.setItem('eventRequestFormData', JSON.stringify(dataToStore));
@ -153,12 +154,27 @@ const EventRequestForm: React.FC = () => {
if (savedData) { if (savedData) {
try { try {
const parsedData = JSON.parse(savedData); const parsedData = JSON.parse(savedData);
// Check if the saved data is stale (older than 24 hours)
const now = Date.now();
const savedTime = parsedData.savedAt || 0;
const staleThreshold = 24 * 60 * 60 * 1000; // 24 hours
if (now - savedTime > staleThreshold) {
// Clear stale data
localStorage.removeItem('eventRequestFormData');
console.log('Cleared stale form data from localStorage');
} else {
// Load the saved data
setFormData(prevData => ({ setFormData(prevData => ({
...prevData, ...prevData,
...parsedData ...parsedData
})); }));
}
} catch (e) { } catch (e) {
console.error('Error parsing saved form data:', e); console.error('Error parsing saved form data:', e);
// Clear corrupted data
localStorage.removeItem('eventRequestFormData');
} }
} }
}, []); }, []);
@ -176,9 +192,29 @@ const EventRequestForm: React.FC = () => {
} }
setFormData(prevData => { setFormData(prevData => {
// Save to localStorage
const updatedData = { ...prevData, ...sectionData }; const updatedData = { ...prevData, ...sectionData };
localStorage.setItem('eventRequestFormData', JSON.stringify(updatedData));
// Save to localStorage
try {
const dataToStore = {
...updatedData,
// Remove file objects before saving to localStorage
other_logos: [],
room_booking_files: [],
invoice: null,
invoice_files: [],
savedAt: Date.now() // Add timestamp for stale data detection
};
localStorage.setItem('eventRequestFormData', JSON.stringify(dataToStore));
// Also update the preview data
window.dispatchEvent(new CustomEvent('formDataUpdated', {
detail: { formData: updatedData }
}));
} catch (error) {
console.error('Error saving form data to localStorage:', error);
}
return updatedData; return updatedData;
}); });
}; };
@ -202,7 +238,7 @@ const EventRequestForm: React.FC = () => {
advertising_format: '', advertising_format: '',
will_or_have_room_booking: false, will_or_have_room_booking: false,
expected_attendance: 0, expected_attendance: 0,
room_booking: null, // No room booking by default room_booking_files: [],
as_funding_required: false, as_funding_required: false,
food_drinks_being_served: false, food_drinks_being_served: false,
itemized_invoice: '', itemized_invoice: '',
@ -236,7 +272,6 @@ const EventRequestForm: React.FC = () => {
} }
setIsSubmitting(true); setIsSubmitting(true);
setError(null);
try { try {
const auth = Authentication.getInstance(); const auth = Authentication.getInstance();
@ -267,8 +302,36 @@ const EventRequestForm: React.FC = () => {
requested_user: userId, requested_user: userId,
name: formData.name, name: formData.name,
location: formData.location, location: formData.location,
start_date_time: new Date(formData.start_date_time).toISOString(), start_date_time: (() => {
end_date_time: formData.end_date_time ? new Date(formData.end_date_time).toISOString() : new Date(formData.start_date_time).toISOString(), try {
const startDate = new Date(formData.start_date_time);
if (isNaN(startDate.getTime())) {
throw new Error('Invalid start date');
}
return startDate.toISOString();
} catch (e) {
throw new Error('Invalid start date format');
}
})(),
end_date_time: (() => {
try {
if (formData.end_date_time) {
const endDate = new Date(formData.end_date_time);
if (isNaN(endDate.getTime())) {
throw new Error('Invalid end date');
}
return endDate.toISOString();
} else {
// Fallback to start date if no end date (should not happen with validation)
const startDate = new Date(formData.start_date_time);
return startDate.toISOString();
}
} catch (e) {
// Fallback to start date
const startDate = new Date(formData.start_date_time);
return startDate.toISOString();
}
})(),
event_description: formData.event_description, event_description: formData.event_description,
flyers_needed: formData.flyers_needed, flyers_needed: formData.flyers_needed,
photography_needed: formData.photography_needed, photography_needed: formData.photography_needed,
@ -277,7 +340,14 @@ const EventRequestForm: React.FC = () => {
itemized_invoice: formData.itemized_invoice, itemized_invoice: formData.itemized_invoice,
flyer_type: formData.flyer_type, flyer_type: formData.flyer_type,
other_flyer_type: formData.other_flyer_type, other_flyer_type: formData.other_flyer_type,
flyer_advertising_start_date: formData.flyer_advertising_start_date ? new Date(formData.flyer_advertising_start_date).toISOString() : '', flyer_advertising_start_date: formData.flyer_advertising_start_date ? (() => {
try {
const advertDate = new Date(formData.flyer_advertising_start_date);
return isNaN(advertDate.getTime()) ? '' : advertDate.toISOString();
} catch (e) {
return '';
}
})() : '',
flyer_additional_requests: formData.flyer_additional_requests, flyer_additional_requests: formData.flyer_additional_requests,
required_logos: formData.required_logos, required_logos: formData.required_logos,
advertising_format: formData.advertising_format, advertising_format: formData.advertising_format,
@ -302,36 +372,126 @@ const EventRequestForm: React.FC = () => {
// This will send the data to the server // This will send the data to the server
const record = await update.create('event_request', submissionData); const record = await update.create('event_request', submissionData);
// Force sync the event requests collection to update IndexedDB // Force sync the event requests collection to update IndexedDB with deletion detection
await dataSync.syncCollection(Collections.EVENT_REQUESTS); await dataSync.syncCollection(Collections.EVENT_REQUESTS, "", "-created", {}, true);
// Upload files if they exist console.log('Event request record created:', record.id);
// Upload files if they exist - handle each file type separately
const fileUploadErrors: string[] = [];
// Upload other logos
if (formData.other_logos.length > 0) { if (formData.other_logos.length > 0) {
try {
console.log('Uploading other logos:', formData.other_logos.length, 'files');
console.log('Other logos files:', formData.other_logos.map(f => ({ name: f.name, size: f.size, type: f.type })));
await fileManager.uploadFiles('event_request', record.id, 'other_logos', formData.other_logos); await fileManager.uploadFiles('event_request', record.id, 'other_logos', formData.other_logos);
console.log('Other logos uploaded successfully');
} catch (error) {
console.error('Failed to upload other logos:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
fileUploadErrors.push(`Failed to upload custom logo files: ${errorMessage}`);
}
} }
if (formData.room_booking) { // Upload room booking files
await fileManager.uploadFile('event_request', record.id, 'room_booking', formData.room_booking); if (formData.room_booking_files && formData.room_booking_files.length > 0) {
try {
console.log('Uploading room booking files:', formData.room_booking_files.length, 'files');
console.log('Room booking files:', formData.room_booking_files.map(f => ({ name: f.name, size: f.size, type: f.type })));
console.log('Using collection:', 'event_request', 'record ID:', record.id, 'field:', 'room_booking');
// Use the correct field name 'room_booking' instead of 'room_booking_files'
await fileManager.uploadFiles('event_request', record.id, 'room_booking', formData.room_booking_files);
console.log('Room booking files uploaded successfully');
} catch (error) {
console.error('Failed to upload room booking files:', error);
console.error('Error details:', {
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
collection: 'event_request',
recordId: record.id,
field: 'room_booking',
fileCount: formData.room_booking_files.length
});
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
fileUploadErrors.push(`Failed to upload room booking files: ${errorMessage}`);
}
} }
// Upload multiple invoice files // Upload invoice files
if (formData.invoice_files && formData.invoice_files.length > 0) { if (formData.invoice_files && formData.invoice_files.length > 0) {
await fileManager.appendFiles('event_request', record.id, 'invoice_files', formData.invoice_files); try {
console.log('Uploading invoice files:', formData.invoice_files.length, 'files');
console.log('Invoice files:', formData.invoice_files.map(f => ({ name: f.name, size: f.size, type: f.type })));
console.log('Using collection:', 'event_request', 'record ID:', record.id, 'field:', 'invoice');
// For backward compatibility, also upload the first file as the main invoice // Use the correct field name 'invoice' instead of 'invoice_files'
if (formData.invoice || formData.invoice_files[0]) { await fileManager.uploadFiles('event_request', record.id, 'invoice', formData.invoice_files);
const mainInvoice = formData.invoice || formData.invoice_files[0]; console.log('Invoice files uploaded successfully');
await fileManager.uploadFile('event_request', record.id, 'invoice', mainInvoice); } catch (error) {
console.error('Failed to upload invoice files:', error);
console.error('Error details:', {
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
collection: 'event_request',
recordId: record.id,
field: 'invoice',
fileCount: formData.invoice_files.length
});
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
fileUploadErrors.push(`Failed to upload invoice files: ${errorMessage}`);
} }
} else if (formData.invoice) { } else if (formData.invoice) {
try {
console.log('Uploading single invoice file:', { name: formData.invoice.name, size: formData.invoice.size, type: formData.invoice.type });
await fileManager.uploadFile('event_request', record.id, 'invoice', formData.invoice); await fileManager.uploadFile('event_request', record.id, 'invoice', formData.invoice);
console.log('Invoice file uploaded successfully');
} catch (error) {
console.error('Failed to upload invoice file:', error);
console.error('Error details:', {
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
collection: 'event_request',
recordId: record.id,
field: 'invoice'
});
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
fileUploadErrors.push(`Failed to upload invoice file: ${errorMessage}`);
}
}
// Show file upload warnings if any occurred
if (fileUploadErrors.length > 0) {
console.warn('File upload errors:', fileUploadErrors);
// Show each file upload error as a separate toast for better UX
fileUploadErrors.forEach(error => {
toast.error(error, {
duration: 6000, // Longer duration for file upload errors
position: 'top-right'
});
});
// Also show a summary toast
toast.error(`Event request submitted successfully, but ${fileUploadErrors.length} file upload(s) failed. Please check the errors above and re-upload the files manually.`, {
duration: 8000,
position: 'top-center'
});
} else {
// Keep success toast for form submission since it's a user action
toast.success('Event request submitted successfully!');
} }
// Clear form data from localStorage // Clear form data from localStorage
localStorage.removeItem('eventRequestFormData'); localStorage.removeItem('eventRequestFormData');
// Keep success toast for form submission since it's a user action // Send email notification to coordinators (non-blocking)
toast.success('Event request submitted successfully!'); try {
await EmailClient.notifyEventRequestSubmission(record.id);
console.log('Event request notification email sent successfully');
} catch (emailError) {
console.error('Failed to send event request notification email:', emailError);
// Don't show error to user - email failure shouldn't disrupt the main flow
}
// Reset form // Reset form
resetForm(); resetForm();
@ -344,7 +504,6 @@ const EventRequestForm: React.FC = () => {
} catch (error) { } catch (error) {
console.error('Error submitting event request:', error); console.error('Error submitting event request:', error);
toast.error('Failed to submit event request. Please try again.'); toast.error('Failed to submit event request. Please try again.');
setError('Failed to submit event request. Please try again.');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -407,11 +566,47 @@ const EventRequestForm: React.FC = () => {
if (!formData.start_date_time || formData.start_date_time.trim() === '') { if (!formData.start_date_time || formData.start_date_time.trim() === '') {
errors.push('Event start date and time is required'); errors.push('Event start date and time is required');
valid = false; valid = false;
} else {
// Validate start date format
try {
const startDate = new Date(formData.start_date_time);
if (isNaN(startDate.getTime())) {
errors.push('Invalid start date and time format');
valid = false;
} else {
// Check if start date is in the future
const now = new Date();
if (startDate <= now) {
errors.push('Event start date must be in the future');
valid = false;
}
}
} catch (e) {
errors.push('Invalid start date and time format');
valid = false;
}
} }
if (!formData.end_date_time) { if (!formData.end_date_time || formData.end_date_time.trim() === '') {
errors.push('Event end time is required'); errors.push('Event end time is required');
valid = false; valid = false;
} else if (formData.start_date_time) {
// Validate end date format and logic
try {
const startDate = new Date(formData.start_date_time);
const endDate = new Date(formData.end_date_time);
if (isNaN(endDate.getTime())) {
errors.push('Invalid end date and time format');
valid = false;
} else if (!isNaN(startDate.getTime()) && endDate <= startDate) {
errors.push('Event end time must be after the start time');
valid = false;
}
} catch (e) {
errors.push('Invalid end date and time format');
valid = false;
}
} }
if (!formData.location || formData.location.trim() === '') { if (!formData.location || formData.location.trim() === '') {
@ -419,13 +614,14 @@ const EventRequestForm: React.FC = () => {
valid = false; valid = false;
} }
if (formData.will_or_have_room_booking === undefined) { if (formData.will_or_have_room_booking === undefined || formData.will_or_have_room_booking === null) {
errors.push('Room booking status is required'); errors.push('Room booking status is required');
valid = false; valid = false;
} }
if (errors.length > 0) { if (errors.length > 0) {
setError(errors[0]); // Show the first error as a toast instead of setting error state
toast.error(errors[0]);
return false; return false;
} }
@ -445,9 +641,9 @@ const EventRequestForm: React.FC = () => {
return false; return false;
} }
// Only require room booking file if will_or_have_room_booking is true // REQUIRED: Room booking files if will_or_have_room_booking is true
if (formData.will_or_have_room_booking && !formData.room_booking) { if (formData.will_or_have_room_booking && formData.room_booking_files.length === 0) {
toast.error('Please upload your room booking confirmation'); toast.error('Room booking files are required when you need a room booking');
return false; return false;
} }
@ -467,10 +663,16 @@ const EventRequestForm: React.FC = () => {
// Validate AS Funding Section // Validate AS Funding Section
const validateASFundingSection = () => { const validateASFundingSection = () => {
if (formData.as_funding_required) { if (formData.as_funding_required || formData.needs_as_funding) {
// REQUIRED: Invoice files if AS funding is needed
if (!formData.invoice_files || formData.invoice_files.length === 0) {
toast.error('Invoice files are required when requesting AS funding');
return false;
}
// Check if invoice data is present and has items // Check if invoice data is present and has items
if (!formData.invoiceData || !formData.invoiceData.items || formData.invoiceData.items.length === 0) { if (!formData.invoiceData || !formData.invoiceData.items || formData.invoiceData.items.length === 0) {
setError('Please add at least one item to your invoice'); toast.error('Please add at least one item to your invoice');
return false; return false;
} }
@ -482,7 +684,7 @@ const EventRequestForm: React.FC = () => {
// Check if the budget exceeds the maximum allowed ($5000 cap regardless of attendance) // Check if the budget exceeds the maximum allowed ($5000 cap regardless of attendance)
const maxBudget = Math.min(formData.expected_attendance * 10, 5000); const maxBudget = Math.min(formData.expected_attendance * 10, 5000);
if (totalBudget > maxBudget) { if (totalBudget > maxBudget) {
setError(`Your budget (${totalBudget.toFixed(2)} dollars) exceeds the maximum allowed (${maxBudget} dollars). The absolute maximum is $5,000.`); toast.error(`Your budget ($${totalBudget.toFixed(2)}) exceeds the maximum allowed ($${maxBudget}). The absolute maximum is $5,000.`);
return false; return false;
} }
} }
@ -813,21 +1015,6 @@ const EventRequestForm: React.FC = () => {
}} }}
className="space-y-6" className="space-y-6"
> >
{error && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
>
<CustomAlert
type="error"
title="Error"
message={error}
icon="heroicons:exclamation-triangle"
/>
</motion.div>
)}
{/* Progress indicator */} {/* Progress indicator */}
<div className="w-full mb-6"> <div className="w-full mb-6">
<div className="flex justify-between mb-2"> <div className="flex justify-between mb-2">

View file

@ -4,6 +4,7 @@ import type { EventRequestFormData } from './EventRequestForm';
import type { EventRequest } from '../../../schemas/pocketbase'; import type { EventRequest } from '../../../schemas/pocketbase';
import { FlyerTypes, LogoOptions } from '../../../schemas/pocketbase'; import { FlyerTypes, LogoOptions } from '../../../schemas/pocketbase';
import CustomAlert from '../universal/CustomAlert'; import CustomAlert from '../universal/CustomAlert';
import { Icon } from '@iconify/react';
// Enhanced animation variants // Enhanced animation variants
const containerVariants = { const containerVariants = {
@ -122,11 +123,26 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
const handleLogoFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleLogoFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
const newFiles = Array.from(e.target.files) as File[]; const newFiles = Array.from(e.target.files) as File[];
setOtherLogoFiles(newFiles); // Combine existing files with new files instead of replacing
onDataChange({ other_logos: newFiles }); const combinedFiles = [...otherLogoFiles, ...newFiles];
setOtherLogoFiles(combinedFiles);
onDataChange({ other_logos: combinedFiles });
} }
}; };
// Handle removing individual files
const handleRemoveLogoFile = (indexToRemove: number) => {
const updatedFiles = otherLogoFiles.filter((_, index) => index !== indexToRemove);
setOtherLogoFiles(updatedFiles);
onDataChange({ other_logos: updatedFiles });
};
// Handle clearing all files
const handleClearAllLogoFiles = () => {
setOtherLogoFiles([]);
onDataChange({ other_logos: [] });
};
// Handle drag events for file upload // Handle drag events for file upload
const handleDragOver = (e: React.DragEvent) => { const handleDragOver = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@ -144,8 +160,10 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const newFiles = Array.from(e.dataTransfer.files) as File[]; const newFiles = Array.from(e.dataTransfer.files) as File[];
setOtherLogoFiles(newFiles); // Combine existing files with new files instead of replacing
onDataChange({ other_logos: newFiles }); const combinedFiles = [...otherLogoFiles, ...newFiles];
setOtherLogoFiles(combinedFiles);
onDataChange({ other_logos: combinedFiles });
} }
}; };
@ -349,20 +367,44 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
{otherLogoFiles.length > 0 ? ( {otherLogoFiles.length > 0 ? (
<> <>
<div className="flex items-center justify-between w-full">
<p className="font-medium text-primary">{otherLogoFiles.length} file(s) selected:</p> <p className="font-medium text-primary">{otherLogoFiles.length} file(s) selected:</p>
<div className="max-h-24 overflow-y-auto text-left w-full"> <button
<ul className="list-disc list-inside text-sm"> type="button"
{otherLogoFiles.map((file, index) => ( onClick={(e) => {
<li key={index} className="truncate">{file.name}</li> e.stopPropagation();
))} handleClearAllLogoFiles();
</ul> }}
className="btn btn-xs btn-outline btn-error"
title="Clear all files"
>
Clear All
</button>
</div> </div>
<p className="text-xs text-gray-500">Click or drag to replace</p> <div className="max-h-32 overflow-y-auto text-left w-full space-y-1">
{otherLogoFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-base-100 p-2 rounded">
<span className="text-sm truncate flex-1">{file.name}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveLogoFile(index);
}}
className="btn btn-xs btn-error ml-2"
title="Remove file"
>
<Icon icon="heroicons:x-mark" className="w-3 h-3" />
</button>
</div>
))}
</div>
<p className="text-xs text-gray-500">Click or drag to add more files</p>
</> </>
) : ( ) : (
<> <>
<p className="font-medium">Drop your logo files here or click to browse</p> <p className="font-medium">Drop your logo files here or click to browse</p>
<p className="text-xs text-gray-500">Please upload transparent logo files (PNG preferred)</p> <p className="text-xs text-gray-500">Please upload transparent logo files (PNG preferred, multiple files allowed)</p>
</> </>
)} )}
</div> </div>

View file

@ -4,6 +4,7 @@ import type { EventRequestFormData } from './EventRequestForm';
import type { EventRequest } from '../../../schemas/pocketbase'; import type { EventRequest } from '../../../schemas/pocketbase';
import CustomAlert from '../universal/CustomAlert'; import CustomAlert from '../universal/CustomAlert';
import FilePreview from '../universal/FilePreview'; import FilePreview from '../universal/FilePreview';
import { Icon } from '@iconify/react';
// Enhanced animation variants // Enhanced animation variants
const containerVariants = { const containerVariants = {
@ -69,11 +70,12 @@ interface TAPFormSectionProps {
} }
const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange }) => { const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange }) => {
const [roomBookingFile, setRoomBookingFile] = useState<File | null>(formData.room_booking); const [roomBookingFiles, setRoomBookingFiles] = useState<File[]>(formData.room_booking_files || []);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [fileError, setFileError] = useState<string | null>(null); const [fileError, setFileError] = useState<string | null>(null);
const [showFilePreview, setShowFilePreview] = useState(false); const [showFilePreview, setShowFilePreview] = useState(false);
const [filePreviewUrl, setFilePreviewUrl] = useState<string | null>(null); const [filePreviewUrl, setFilePreviewUrl] = useState<string | null>(null);
const [selectedPreviewFile, setSelectedPreviewFile] = useState<File | null>(null);
// Add style tag for hidden arrows // Add style tag for hidden arrows
useEffect(() => { useEffect(() => {
@ -89,27 +91,58 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
// Handle room booking file upload with size limit // Handle room booking file upload with size limit
const handleRoomBookingFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleRoomBookingFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0]; const newFiles = Array.from(e.target.files) as File[];
// Check file size - 1MB limit // Check file sizes - 1MB limit for each file
if (file.size > 1024 * 1024) { const oversizedFiles = newFiles.filter(file => file.size > 1024 * 1024);
setFileError("Room booking file size must be under 1MB"); if (oversizedFiles.length > 0) {
setFileError(`${oversizedFiles.length} file(s) exceed 1MB limit`);
return; return;
} }
setFileError(null); setFileError(null);
setRoomBookingFile(file); // Combine existing files with new files instead of replacing
onDataChange({ room_booking: file }); const combinedFiles = [...roomBookingFiles, ...newFiles];
setRoomBookingFiles(combinedFiles);
onDataChange({ room_booking_files: combinedFiles });
// Create preview URL // Create preview URL for the first new file
if (filePreviewUrl) { if (filePreviewUrl) {
URL.revokeObjectURL(filePreviewUrl); URL.revokeObjectURL(filePreviewUrl);
} }
const url = URL.createObjectURL(file); const url = URL.createObjectURL(newFiles[0]);
setFilePreviewUrl(url); setFilePreviewUrl(url);
setSelectedPreviewFile(newFiles[0]);
} }
}; };
// Handle removing individual files
const handleRemoveFile = (indexToRemove: number) => {
const updatedFiles = roomBookingFiles.filter((_, index) => index !== indexToRemove);
setRoomBookingFiles(updatedFiles);
onDataChange({ room_booking_files: updatedFiles });
// Clear preview if we removed the previewed file
if (selectedPreviewFile && updatedFiles.length === 0) {
if (filePreviewUrl) {
URL.revokeObjectURL(filePreviewUrl);
setFilePreviewUrl(null);
}
setSelectedPreviewFile(null);
}
};
// Handle clearing all files
const handleClearAllFiles = () => {
setRoomBookingFiles([]);
onDataChange({ room_booking_files: [] });
if (filePreviewUrl) {
URL.revokeObjectURL(filePreviewUrl);
setFilePreviewUrl(null);
}
setSelectedPreviewFile(null);
};
// Handle drag events for file upload // Handle drag events for file upload
const handleDragOver = (e: React.DragEvent) => { const handleDragOver = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@ -126,24 +159,28 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
setIsDragging(false); setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0]; const newFiles = Array.from(e.dataTransfer.files) as File[];
// Check file size - 1MB limit // Check file sizes - 1MB limit for each file
if (file.size > 1024 * 1024) { const oversizedFiles = newFiles.filter(file => file.size > 1024 * 1024);
setFileError("Room booking file size must be under 1MB"); if (oversizedFiles.length > 0) {
setFileError(`${oversizedFiles.length} file(s) exceed 1MB limit`);
return; return;
} }
setFileError(null); setFileError(null);
setRoomBookingFile(file); // Combine existing files with new files instead of replacing
onDataChange({ room_booking: file }); const combinedFiles = [...roomBookingFiles, ...newFiles];
setRoomBookingFiles(combinedFiles);
onDataChange({ room_booking_files: combinedFiles });
// Create preview URL // Create preview URL for the first new file
if (filePreviewUrl) { if (filePreviewUrl) {
URL.revokeObjectURL(filePreviewUrl); URL.revokeObjectURL(filePreviewUrl);
} }
const url = URL.createObjectURL(file); const url = URL.createObjectURL(newFiles[0]);
setFilePreviewUrl(url); setFilePreviewUrl(url);
setSelectedPreviewFile(newFiles[0]);
} }
}; };
@ -262,6 +299,11 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
<span className="label-text font-medium text-lg">Room Booking Confirmation</span> <span className="label-text font-medium text-lg">Room Booking Confirmation</span>
{formData.will_or_have_room_booking && <span className="label-text-alt text-error">*</span>} {formData.will_or_have_room_booking && <span className="label-text-alt text-error">*</span>}
</label> </label>
{formData.will_or_have_room_booking && (
<p className="text-sm text-gray-500 mb-3">
<strong>Required:</strong> Upload your room booking confirmation document.
</p>
)}
{fileError && ( {fileError && (
<div className="mt-2 mb-2"> <div className="mt-2 mb-2">
@ -292,6 +334,7 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
className="hidden" className="hidden"
onChange={handleRoomBookingFileChange} onChange={handleRoomBookingFileChange}
accept=".pdf,.png,.jpg,.jpeg" accept=".pdf,.png,.jpg,.jpeg"
multiple
/> />
<div className="flex flex-col items-center justify-center gap-3"> <div className="flex flex-col items-center justify-center gap-3">
@ -304,16 +347,46 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
</svg> </svg>
</motion.div> </motion.div>
{roomBookingFile ? ( {roomBookingFiles.length > 0 ? (
<> <>
<p className="font-medium text-primary">File selected:</p> <div className="flex items-center justify-between w-full">
<p className="text-sm">{roomBookingFile.name}</p> <p className="font-medium text-primary">{roomBookingFiles.length} file(s) selected:</p>
<p className="text-xs text-gray-500">Click or drag to replace (Max size: 1MB)</p> <button
type="button"
onClick={(e) => {
e.stopPropagation();
handleClearAllFiles();
}}
className="btn btn-xs btn-outline btn-error"
title="Clear all files"
>
Clear All
</button>
</div>
<div className="max-h-32 overflow-y-auto text-left w-full space-y-1">
{roomBookingFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-base-100 p-2 rounded">
<span className="text-sm truncate flex-1">{file.name}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveFile(index);
}}
className="btn btn-xs btn-error ml-2"
title="Remove file"
>
<Icon icon="heroicons:x-mark" className="w-3 h-3" />
</button>
</div>
))}
</div>
<p className="text-xs text-gray-500">Click or drag to add more files (Max size: 1MB each)</p>
</> </>
) : ( ) : (
<> <>
<p className="font-medium">Drop your file here or click to browse</p> <p className="font-medium">Drop your files here or click to browse</p>
<p className="text-xs text-gray-500">Accepted formats: PDF, PNG, JPG (Max size: 1MB)</p> <p className="text-xs text-gray-500">Accepted formats: PDF, PNG, JPG (Max size: 1MB each, multiple files allowed)</p>
</> </>
)} )}
</div> </div>
@ -329,20 +402,20 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
)} )}
{/* Preview File Button - Outside the upload area */} {/* Preview File Button - Outside the upload area */}
{formData.will_or_have_room_booking && roomBookingFile && ( {formData.will_or_have_room_booking && roomBookingFiles.length > 0 && (
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<button <button
type="button" type="button"
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={toggleFilePreview} onClick={toggleFilePreview}
> >
{showFilePreview ? 'Hide Preview' : 'Preview File'} {showFilePreview ? 'Hide Preview' : `Preview Files (${roomBookingFiles.length})`}
</button> </button>
</div> </div>
)} )}
{/* File Preview Component */} {/* File Preview Component */}
{showFilePreview && filePreviewUrl && roomBookingFile && ( {showFilePreview && roomBookingFiles.length > 0 && (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@ -350,7 +423,7 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
className="mt-4 p-4 bg-base-200 rounded-lg" className="mt-4 p-4 bg-base-200 rounded-lg"
> >
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h3 className="font-medium">File Preview</h3> <h3 className="font-medium">File Preview ({roomBookingFiles.length} files)</h3>
<button <button
type="button" type="button"
className="btn btn-sm btn-circle" className="btn btn-sm btn-circle"
@ -361,7 +434,17 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
</svg> </svg>
</button> </button>
</div> </div>
<FilePreview url={filePreviewUrl} filename={roomBookingFile.name} /> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{roomBookingFiles.map((file, index) => {
const url = URL.createObjectURL(file);
return (
<div key={index} className="border rounded-lg p-2">
<p className="text-sm font-medium mb-2 truncate">{file.name}</p>
<FilePreview url={url} filename={file.name} />
</div>
);
})}
</div>
</motion.div> </motion.div>
)} )}
</motion.div> </motion.div>

View file

@ -258,12 +258,14 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
return; return;
} }
// Use DataSyncService to get data from IndexedDB with forced sync // Use DataSyncService to get data from IndexedDB with forced sync and deletion detection
const updatedRequests = await dataSync.getData<EventRequest>( const updatedRequests = await dataSync.getData<EventRequest>(
Collections.EVENT_REQUESTS, Collections.EVENT_REQUESTS,
true, // Force sync true, // Force sync
`requested_user="${userId}"`, `requested_user="${userId}"`,
'-created' '-created',
{}, // expand
true // Enable deletion detection for user-specific requests
); );
setEventRequests(updatedRequests); setEventRequests(updatedRequests);

View file

@ -53,7 +53,7 @@ try {
"", "",
"-created", "-created",
{ {
expand: ["requested_user"], expand: "requested_user",
} }
) )
.catch((err) => { .catch((err) => {
@ -308,7 +308,7 @@ try {
Collections.EVENT_REQUESTS, Collections.EVENT_REQUESTS,
"", "",
"-created", "-created",
{ expand: "requested_user" } "requested_user"
); );
} catch (err) { } catch (err) {
console.error("Error during initial data sync:", err); console.error("Error during initial data sync:", err);

View file

@ -1,12 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion } from 'framer-motion';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema'; import { Collections } from '../../../schemas/pocketbase/schema';
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema'; import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
import { FileManager } from '../../../scripts/pocketbase/FileManager';
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import CustomAlert from '../universal/CustomAlert'; import CustomAlert from '../universal/CustomAlert';
import UniversalFilePreview from '../universal/FilePreview';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Authentication } from '../../../scripts/pocketbase/Authentication';
// Extended EventRequest interface with additional properties needed for this component // Extended EventRequest interface with additional properties needed for this component
@ -29,10 +28,11 @@ interface ExtendedEventRequest extends SchemaEventRequest {
invoice_files?: string[]; // Array of invoice file IDs invoice_files?: string[]; // Array of invoice file IDs
flyer_files?: string[]; // Add this for PR-related files flyer_files?: string[]; // Add this for PR-related files
files?: string[]; // Generic files field files?: string[]; // Generic files field
room_reservation_needed?: boolean; will_or_have_room_booking?: boolean;
room_reservation_location?: string; room_booking_files?: string[]; // CHANGED: Multiple room booking files instead of single
room_reservation_confirmed?: boolean; room_reservation_needed?: boolean; // Keep for backward compatibility
additional_notes?: string; additional_notes?: string;
flyers_completed?: boolean; // Track if flyers have been completed by PR team
} }
interface EventRequestDetailsProps { interface EventRequestDetailsProps {
@ -82,7 +82,7 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
setFileUrl(secureUrl); setFileUrl(secureUrl);
// Determine file type from extension // Determine file type from extension
const extension = fileName.split('.').pop()?.toLowerCase() || ''; const extension = (typeof fileName === 'string' ? fileName.split('.').pop()?.toLowerCase() : '') || '';
setFileType(extension); setFileType(extension);
setIsLoading(false); setIsLoading(false);
@ -624,7 +624,7 @@ const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request })
) : ( ) : (
<Icon icon="mdi:file-document" className="h-8 w-8 text-secondary" /> <Icon icon="mdi:file-document" className="h-8 w-8 text-secondary" />
)} )}
<div className="flex-grow"> <div className="flex-1 min-w-0">
<p className="font-medium truncate" title={fileId}> <p className="font-medium truncate" title={fileId}>
{displayName} {displayName}
</p> </p>
@ -712,6 +712,28 @@ const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request })
</div> </div>
</motion.div> </motion.div>
{/* Copyable Invoice Format */}
<motion.div
className="bg-base-100/10 p-5 rounded-lg border border-base-100/10"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<div className="flex items-center gap-4 mb-4">
<div className="bg-info/20 p-3 rounded-full">
<Icon icon="mdi:content-copy" className="h-6 w-6 text-info" />
</div>
<div>
<h3 className="text-lg font-semibold">Copyable Format</h3>
<p className="text-sm text-gray-400">Copy formatted invoice data for easy sharing</p>
</div>
</div>
<div className="mt-4">
<CopyableInvoiceFormat invoiceData={invoiceData} />
</div>
</motion.div>
{/* File Preview Modal */} {/* File Preview Modal */}
<FilePreviewModal <FilePreviewModal
isOpen={isPreviewModalOpen} isOpen={isPreviewModalOpen}
@ -725,6 +747,150 @@ const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request })
); );
}; };
// Component for copyable invoice format
const CopyableInvoiceFormat: React.FC<{ invoiceData: any }> = ({ invoiceData }) => {
const [copied, setCopied] = useState(false);
const [formattedText, setFormattedText] = useState<string>('');
useEffect(() => {
if (!invoiceData) {
setFormattedText('No invoice data available');
return;
}
try {
// Parse invoice data if it's a string
let parsedInvoice = null;
if (typeof invoiceData === 'string') {
try {
parsedInvoice = JSON.parse(invoiceData);
} catch (e) {
console.error('Failed to parse invoice data string:', e);
setFormattedText('Invalid invoice data format');
return;
}
} else if (typeof invoiceData === 'object' && invoiceData !== null) {
parsedInvoice = invoiceData;
} else {
setFormattedText('No structured invoice data available');
return;
}
// Extract items array
let items = [];
if (parsedInvoice.items && Array.isArray(parsedInvoice.items)) {
items = parsedInvoice.items;
} else if (Array.isArray(parsedInvoice)) {
items = parsedInvoice;
} else if (parsedInvoice.items && typeof parsedInvoice.items === 'object') {
items = [parsedInvoice.items]; // Wrap single item in array
} else {
// Try to find any array in the object
for (const key in parsedInvoice) {
if (Array.isArray(parsedInvoice[key])) {
items = parsedInvoice[key];
break;
}
}
}
// If we still don't have items, check if the object itself looks like an item
if (items.length === 0 && (parsedInvoice.item || parsedInvoice.description || parsedInvoice.name)) {
items = [parsedInvoice];
}
// Format the items into the required string format
const formattedItems = items.map((item: any) => {
const quantity = parseFloat(item?.quantity || 1);
const itemName = typeof item?.item === 'object'
? JSON.stringify(item.item)
: (item?.item || item?.description || item?.name || 'N/A');
const unitPrice = parseFloat(item?.unit_price || item?.unitPrice || item?.price || 0);
return `${quantity} ${itemName} x${unitPrice.toFixed(2)} each`;
}).join(' | ');
// Get tax, tip and total
const tax = parseFloat(parsedInvoice.tax || parsedInvoice.taxAmount || 0);
const tip = parseFloat(parsedInvoice.tip || parsedInvoice.tipAmount || 0);
const total = parseFloat(parsedInvoice.total || 0) ||
items.reduce((sum: number, item: any) => {
const quantity = parseFloat(item?.quantity || 1);
const price = parseFloat(item?.unit_price || item?.unitPrice || item?.price || 0);
return sum + (quantity * price);
}, 0) + tax + tip;
// Get vendor/location
const location = parsedInvoice.vendor || parsedInvoice.location || 'Unknown Vendor';
// Build the final formatted string
let result = formattedItems;
if (tax > 0) {
result += ` | Tax = ${tax.toFixed(2)}`;
}
if (tip > 0) {
result += ` | Tip = ${tip.toFixed(2)}`;
}
result += ` | Total = ${total.toFixed(2)} from ${location}`;
setFormattedText(result);
} catch (error) {
console.error('Error formatting invoice data:', error);
setFormattedText('Error formatting invoice data');
}
}, [invoiceData]);
const copyToClipboard = () => {
navigator.clipboard.writeText(formattedText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success('Copied to clipboard!');
})
.catch(err => {
console.error('Failed to copy text: ', err);
toast.error('Failed to copy text');
});
};
return (
<div className="space-y-4">
<div className="bg-base-200/30 p-4 rounded-lg">
<div className="flex justify-between items-start mb-2">
<label className="text-sm font-medium text-gray-400">Formatted Invoice Data</label>
<button
onClick={copyToClipboard}
className="btn btn-sm btn-primary gap-2"
disabled={!formattedText || formattedText.includes('No') || formattedText.includes('Error')}
>
{copied ? (
<>
<Icon icon="mdi:check" className="h-4 w-4" />
Copied!
</>
) : (
<>
<Icon icon="mdi:content-copy" className="h-4 w-4" />
Copy
</>
)}
</button>
</div>
<div className="bg-base-300/50 p-3 rounded-lg mt-2 whitespace-pre-wrap break-words text-sm">
{formattedText}
</div>
<p className="text-xs text-gray-400 mt-2">
Format: N_1 {'{item_1}'} x{'{cost_1}'} each | N_2 {'{item_2}'} x{'{cost_2}'} each | Tax = {'{tax}'} | Tip = {'{tip}'} | Total = {'{total}'} from {'{location}'}
</p>
</div>
</div>
);
};
// Separate component for invoice table // Separate component for invoice table
const InvoiceTable: React.FC<{ invoiceData: any, expectedAttendance?: number }> = ({ invoiceData, expectedAttendance }) => { const InvoiceTable: React.FC<{ invoiceData: any, expectedAttendance?: number }> = ({ invoiceData, expectedAttendance }) => {
// If no invoice data is provided, show a message // If no invoice data is provided, show a message
@ -913,6 +1079,8 @@ const InvoiceTable: React.FC<{ invoiceData: any, expectedAttendance?: number }>
const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }) => { const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }) => {
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState<boolean>(false); const [isPreviewModalOpen, setIsPreviewModalOpen] = useState<boolean>(false);
const [selectedFile, setSelectedFile] = useState<{ name: string, displayName: string }>({ name: '', displayName: '' }); const [selectedFile, setSelectedFile] = useState<{ name: string, displayName: string }>({ name: '', displayName: '' });
const [flyersCompleted, setFlyersCompleted] = useState<boolean>(request.flyers_completed || false);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
// Format date for display // Format date for display
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
@ -931,8 +1099,33 @@ const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }
} }
}; };
// Handle flyers completed checkbox change
const handleFlyersCompletedChange = async (completed: boolean) => {
setIsUpdating(true);
try {
const { Update } = await import('../../../scripts/pocketbase/Update');
const update = Update.getInstance();
await update.updateField("event_request", request.id, "flyers_completed", completed);
setFlyersCompleted(completed);
toast.success(`Flyers completion status updated to ${completed ? 'completed' : 'not completed'}`);
} catch (error) {
console.error('Failed to update flyers completed status:', error);
toast.error('Failed to update flyers completion status');
} finally {
setIsUpdating(false);
}
};
// Sync local state with request prop changes
useEffect(() => {
setFlyersCompleted(request.flyers_completed || false);
}, [request.flyers_completed]);
// Use the same utility functions as in the ASFundingTab // Use the same utility functions as in the ASFundingTab
const getFileExtension = (filename: string): string => { const getFileExtension = (filename: string): string => {
if (!filename || typeof filename !== 'string') return '';
const parts = filename.split('.'); const parts = filename.split('.');
return parts.length > 1 ? parts.pop()?.toLowerCase() || '' : ''; return parts.length > 1 ? parts.pop()?.toLowerCase() || '' : '';
}; };
@ -947,6 +1140,7 @@ const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }
}; };
const getFriendlyFileName = (filename: string, maxLength: number = 20): string => { const getFriendlyFileName = (filename: string, maxLength: number = 20): string => {
if (!filename || typeof filename !== 'string') return 'Unknown File';
const basename = filename.split('/').pop() || filename; const basename = filename.split('/').pop() || filename;
if (basename.length <= maxLength) return basename; if (basename.length <= maxLength) return basename;
const extension = getFileExtension(basename); const extension = getFileExtension(basename);
@ -993,6 +1187,46 @@ const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }
</div> </div>
</div> </div>
{/* Flyers Completed Checkbox - Only show if flyers are needed */}
{request.flyers_needed && (
<motion.div
className="bg-base-300/20 p-4 rounded-lg"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.05 }}
>
<h4 className="text-sm font-medium text-gray-400 mb-3">Completion Status</h4>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={flyersCompleted}
onChange={(e) => handleFlyersCompletedChange(e.target.checked)}
disabled={isUpdating}
className="checkbox checkbox-primary"
/>
<label className="text-sm font-medium">
Flyers completed by PR team
</label>
{isUpdating && (
<div className="loading loading-spinner loading-sm"></div>
)}
</div>
<div className="mt-2">
{flyersCompleted ? (
<span className="badge badge-success gap-1">
<Icon icon="mdi:check-circle" className="h-3 w-3" />
Completed
</span>
) : (
<span className="badge badge-warning gap-1">
<Icon icon="mdi:clock" className="h-3 w-3" />
Pending
</span>
)}
</div>
</motion.div>
)}
{request.flyers_needed && ( {request.flyers_needed && (
<motion.div <motion.div
className="space-y-4" className="space-y-4"
@ -1270,6 +1504,9 @@ const EventRequestDetails = ({
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
const [newStatus, setNewStatus] = useState<"submitted" | "pending" | "completed" | "declined">("pending"); const [newStatus, setNewStatus] = useState<"submitted" | "pending" | "completed" | "declined">("pending");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Add state for decline reason modal
const [isDeclineModalOpen, setIsDeclineModalOpen] = useState(false);
const [declineReason, setDeclineReason] = useState<string>('');
const [alertInfo, setAlertInfo] = useState<{ show: boolean; type: "success" | "error" | "warning" | "info"; message: string }>({ const [alertInfo, setAlertInfo] = useState<{ show: boolean; type: "success" | "error" | "warning" | "info"; message: string }>({
show: false, show: false,
type: "info", type: "info",
@ -1300,8 +1537,14 @@ const EventRequestDetails = ({
}; };
const handleStatusChange = async (newStatus: "submitted" | "pending" | "completed" | "declined") => { const handleStatusChange = async (newStatus: "submitted" | "pending" | "completed" | "declined") => {
if (newStatus === 'declined') {
// Open decline reason modal instead of immediate confirmation
setDeclineReason('');
setIsDeclineModalOpen(true);
} else {
setNewStatus(newStatus); setNewStatus(newStatus);
setIsConfirmModalOpen(true); setIsConfirmModalOpen(true);
}
}; };
const confirmStatusChange = async () => { const confirmStatusChange = async () => {
@ -1327,6 +1570,72 @@ const EventRequestDetails = ({
} }
}; };
// Handle decline with reason
const handleDeclineWithReason = async () => {
if (!declineReason.trim()) {
toast.error('Please provide a reason for declining');
return;
}
setIsSubmitting(true);
try {
// Use Update service to update both status and decline reason
const { Update } = await import('../../../scripts/pocketbase/Update');
const update = Update.getInstance();
await update.updateFields("event_request", request.id, {
status: 'declined',
declined_reason: declineReason
});
// Send email notifications
const { EmailClient } = await import('../../../scripts/email/EmailClient');
const auth = Authentication.getInstance();
const changedByUserId = auth.getUserId();
await EmailClient.notifyEventRequestStatusChange(
request.id,
request.status,
'declined',
changedByUserId || undefined,
declineReason
);
// Send design team notification if PR materials were needed
if (request.flyers_needed) {
await EmailClient.notifyDesignTeam(request.id, 'declined');
}
setAlertInfo({
show: true,
type: "success",
message: "Event request has been declined successfully."
});
setIsDeclineModalOpen(false);
setDeclineReason('');
// Call the parent's onStatusChange if needed for UI updates
await onStatusChange(request.id, 'declined');
} catch (error) {
console.error('Error declining request:', error);
setAlertInfo({
show: true,
type: "error",
message: "Failed to decline event request. Please try again."
});
} finally {
setIsSubmitting(false);
}
};
// Cancel decline action
const cancelDecline = () => {
setIsDeclineModalOpen(false);
setDeclineReason('');
};
return ( return (
<div className="bg-transparent w-full"> <div className="bg-transparent w-full">
{/* Tabs navigation */} {/* Tabs navigation */}
@ -1479,6 +1788,11 @@ const EventRequestDetails = ({
<label className="text-xs text-gray-400">Start Date & Time</label> <label className="text-xs text-gray-400">Start Date & Time</label>
<p className="text-white">{formatDate(request.start_date_time)}</p> <p className="text-white">{formatDate(request.start_date_time)}</p>
</div> </div>
<div>
<label className="text-xs text-gray-400">End Date & Time</label>
<p className="text-white">{formatDate(request.end_date_time)}</p>
</div>
</div> </div>
</div> </div>
@ -1503,14 +1817,14 @@ const EventRequestDetails = ({
</div> </div>
<div className="flex items-center justify-between bg-base-200/30 p-3 rounded-lg"> <div className="flex items-center justify-between bg-base-200/30 p-3 rounded-lg">
<p className="text-white">Room Reservation Needed</p> <p className="text-white">Room Reservation Needed</p>
<div className={`badge ${request.room_reservation_needed ? 'badge-success' : 'badge-ghost'}`}> <div className={`badge ${request.will_or_have_room_booking ? 'badge-success' : 'badge-ghost'}`}>
{request.room_reservation_needed ? 'Yes' : 'No'} {request.will_or_have_room_booking ? 'Yes' : 'No'}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{request.room_reservation_needed ? ( {request.will_or_have_room_booking ? (
<div className="bg-base-100/10 p-5 rounded-lg border border-base-100/10"> <div className="bg-base-100/10 p-5 rounded-lg border border-base-100/10">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center"> <h3 className="text-lg font-semibold text-white mb-4 flex items-center">
<Icon icon="mdi:map-marker-outline" className="h-5 w-5 mr-2 text-primary" /> <Icon icon="mdi:map-marker-outline" className="h-5 w-5 mr-2 text-primary" />
@ -1519,14 +1833,40 @@ const EventRequestDetails = ({
<div className="space-y-3"> <div className="space-y-3">
<div className="bg-base-200/30 p-3 rounded-lg"> <div className="bg-base-200/30 p-3 rounded-lg">
<label className="text-xs text-gray-400 block mb-1">Room/Location</label> <label className="text-xs text-gray-400 block mb-1">Room/Location</label>
<p className="text-white font-medium">{request.room_reservation_location || 'Not specified'}</p> <p className="text-white font-medium">{request.location || 'Not specified'}</p>
</div> </div>
<div className="bg-base-200/30 p-3 rounded-lg"> <div className="bg-base-200/30 p-3 rounded-lg">
<label className="text-xs text-gray-400 block mb-1">Confirmation Status</label> <label className="text-xs text-gray-400 block mb-1">Confirmation Status</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={`badge ${request.room_reservation_confirmed ? 'badge-success' : 'badge-warning'}`}> <div className={`badge ${request.room_booking_files && request.room_booking_files.length > 0 ? 'badge-success' : 'badge-warning'}`}>
{request.room_reservation_confirmed ? 'Confirmed' : 'Pending'} {request.room_booking_files && request.room_booking_files.length > 0 ? 'Booking Files Uploaded' : 'No Booking Files'}
</div> </div>
{request.room_booking_files && request.room_booking_files.length > 0 && (
<div className="flex gap-2">
{request.room_booking_files.map((fileId, index) => (
<button
key={index}
onClick={() => {
// Dispatch event to update file preview modal
const event = new CustomEvent('filePreviewStateChange', {
detail: {
url: `https://pocketbase.ieeeucsd.org/api/files/event_request/${request.id}/${fileId}`,
filename: fileId
}
});
window.dispatchEvent(event);
// Open the modal
const modal = document.getElementById('file-preview-modal') as HTMLDialogElement;
if (modal) modal.showModal();
}}
className="btn btn-xs btn-primary"
>
View File {index + 1}
</button>
))}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@ -1617,6 +1957,73 @@ const EventRequestDetails = ({
</div> </div>
</div> </div>
)} )}
{/* Decline Reason Modal */}
{isDeclineModalOpen && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[300] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-base-300 rounded-lg p-6 w-full max-w-md"
>
<h3 className="text-lg font-bold mb-4">Decline Event Request</h3>
<p className="text-gray-300 mb-4">
Please provide a reason for declining "{request.name}". This will be sent to the submitter and they will need to resubmit with proper information.
</p>
<textarea
className="textarea textarea-bordered w-full h-32 bg-base-100 text-white border-base-300 focus:border-primary"
placeholder="Enter decline reason (required)..."
value={declineReason}
onChange={(e) => setDeclineReason(e.target.value)}
maxLength={500}
/>
<div className="text-xs text-gray-400 mb-4">
{declineReason.length}/500 characters
</div>
<div className="flex justify-end gap-3">
<button
className="btn btn-ghost"
onClick={cancelDecline}
disabled={isSubmitting}
>
Cancel
</button>
<button
className="btn btn-error"
onClick={handleDeclineWithReason}
disabled={!declineReason.trim() || isSubmitting}
>
{isSubmitting ? (
<>
<span className="loading loading-spinner loading-xs"></span>
Declining...
</>
) : (
'Decline Request'
)}
</button>
</div>
</motion.div>
</div>
)}
{/* File Preview Modal */}
<dialog id="file-preview-modal" className="modal modal-bottom sm:modal-middle">
<div className="modal-box bg-base-200 p-0 overflow-hidden max-w-4xl">
<div className="p-4">
<UniversalFilePreview isModal={true} />
</div>
<div className="modal-action mt-0 p-4 border-t border-base-300">
<form method="dialog">
<button className="btn btn-sm">Close</button>
</form>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div> </div>
); );
}; };

View file

@ -1,11 +1,8 @@
import React, { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion } from 'framer-motion';
import { Get } from '../../../scripts/pocketbase/Get';
import { Update } from '../../../scripts/pocketbase/Update';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { DataSyncService } from '../../../scripts/database/DataSyncService'; import { DataSyncService } from '../../../scripts/database/DataSyncService';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import EventRequestDetails from './EventRequestDetails';
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema'; import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
import { Collections } from '../../../schemas/pocketbase/schema'; import { Collections } from '../../../schemas/pocketbase/schema';
@ -27,6 +24,8 @@ interface ExtendedEventRequest extends SchemaEventRequest {
invoice_data?: any; invoice_data?: any;
invoice_files?: string[]; // Array of invoice file IDs invoice_files?: string[]; // Array of invoice file IDs
status: "submitted" | "pending" | "completed" | "declined"; status: "submitted" | "pending" | "completed" | "declined";
declined_reason?: string; // Reason for declining the event request
flyers_completed?: boolean; // Track if flyers have been completed by PR team
} }
interface EventRequestManagementTableProps { interface EventRequestManagementTableProps {
@ -45,14 +44,18 @@ const EventRequestManagementTable = ({
const [eventRequests, setEventRequests] = useState<ExtendedEventRequest[]>(initialEventRequests); const [eventRequests, setEventRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
const [filteredRequests, setFilteredRequests] = useState<ExtendedEventRequest[]>(initialEventRequests); const [filteredRequests, setFilteredRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false); const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('active');
const [searchTerm, setSearchTerm] = useState<string>(''); const [searchTerm, setSearchTerm] = useState<string>('');
const [sortField, setSortField] = useState<string>('created'); const [sortField, setSortField] = useState<string>('start_date_time');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const dataSync = DataSyncService.getInstance(); const dataSync = DataSyncService.getInstance();
// Add state for update modal // Add state for update modal
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState<boolean>(false); const [isUpdateModalOpen, setIsUpdateModalOpen] = useState<boolean>(false);
const [requestToUpdate, setRequestToUpdate] = useState<ExtendedEventRequest | null>(null); const [requestToUpdate, setRequestToUpdate] = useState<ExtendedEventRequest | null>(null);
// Add state for decline reason modal
const [isDeclineModalOpen, setIsDeclineModalOpen] = useState<boolean>(false);
const [declineReason, setDeclineReason] = useState<string>('');
const [requestToDecline, setRequestToDecline] = useState<ExtendedEventRequest | null>(null);
// Refresh event requests // Refresh event requests
const refreshEventRequests = async () => { const refreshEventRequests = async () => {
@ -65,13 +68,14 @@ const EventRequestManagementTable = ({
// console.log("Fetching event requests..."); // console.log("Fetching event requests...");
// Use DataSyncService to get data from IndexedDB with forced sync // Use DataSyncService to get data from IndexedDB with forced sync and deletion detection
const updatedRequests = await dataSync.getData<ExtendedEventRequest>( const updatedRequests = await dataSync.getData<ExtendedEventRequest>(
Collections.EVENT_REQUESTS, Collections.EVENT_REQUESTS,
true, // Force sync true, // Force sync
'', // No filter '', // No filter - get all requests
'-created', '-created',
'requested_user' // This is correct but we need to ensure it's working in the DataSyncService 'requested_user', // Expand user data
true // Enable deletion detection for all event requests
); );
// If we still have "Unknown" users, try to fetch them directly // If we still have "Unknown" users, try to fetch them directly
@ -133,9 +137,19 @@ const EventRequestManagementTable = ({
// Apply status filter // Apply status filter
if (statusFilter !== 'all') { if (statusFilter !== 'all') {
filtered = filtered.filter(request => if (statusFilter === 'active') {
request.status?.toLowerCase() === statusFilter.toLowerCase() // Filter to show only submitted and pending events (hide completed and declined)
); filtered = filtered.filter(request => {
const status = request.status?.toLowerCase();
return status === 'submitted' || status === 'pending' || !status; // Include requests without status (assume pending)
});
} else {
// For specific status filters, treat empty status as 'pending'
filtered = filtered.filter(request => {
const status = request.status?.toLowerCase() || 'pending'; // Default empty status to 'pending'
return status === statusFilter.toLowerCase();
});
}
} }
// Apply search filter // Apply search filter
@ -180,40 +194,125 @@ const EventRequestManagementTable = ({
}; };
// Update event request status // Update event request status
const updateEventRequestStatus = async (id: string, status: "submitted" | "pending" | "completed" | "declined"): Promise<void> => { const updateEventRequestStatus = async (id: string, status: "submitted" | "pending" | "completed" | "declined", declineReason?: string): Promise<void> => {
try { try {
await onStatusChange(id, status); // Find the event request to get its current status and name
const eventRequest = eventRequests.find(req => req.id === id);
const eventName = eventRequest?.name || 'Event';
const previousStatus = eventRequest?.status;
// Find the event request to get its name // If declining, update with decline reason
if (status === 'declined' && declineReason) {
const { Update } = await import('../../../scripts/pocketbase/Update');
const update = Update.getInstance();
await update.updateFields("event_request", id, {
status: status,
declined_reason: declineReason
});
} else {
await onStatusChange(id, status);
}
// Update local state
setEventRequests(prev =>
prev.map(request =>
request.id === id ? {
...request,
status,
...(status === 'declined' && declineReason ? { declined_reason: declineReason } : {})
} : request
)
);
setFilteredRequests(prev =>
prev.map(request =>
request.id === id ? {
...request,
status,
...(status === 'declined' && declineReason ? { declined_reason: declineReason } : {})
} : request
)
);
toast.success(`"${eventName}" status updated to ${status}`);
// Send email notification for status change
try {
const { EmailClient } = await import('../../../scripts/email/EmailClient');
const auth = Authentication.getInstance();
const changedByUserId = auth.getUserId();
if (previousStatus && previousStatus !== status) {
await EmailClient.notifyEventRequestStatusChange(
id,
previousStatus,
status,
changedByUserId || undefined,
status === 'declined' ? declineReason : undefined
);
console.log('Event request status change notification email sent successfully');
}
// Send design team notifications for PR-related actions
if (eventRequest?.flyers_needed) {
if (status === 'declined') {
await EmailClient.notifyDesignTeam(id, 'declined');
console.log('Design team notified of declined PR request');
}
}
} catch (emailError) {
console.error('Failed to send event request status change notification email:', emailError);
// Don't show error to user - email failure shouldn't disrupt the main operation
}
} catch (error) {
console.error('Error updating status:', error);
toast.error('Failed to update status');
}
};
// Update PR status (flyers_completed)
const updatePRStatus = async (id: string, completed: boolean): Promise<void> => {
try {
const { Update } = await import('../../../scripts/pocketbase/Update');
const update = Update.getInstance();
await update.updateField("event_request", id, "flyers_completed", completed);
// Find the event request to get its details
const eventRequest = eventRequests.find(req => req.id === id); const eventRequest = eventRequests.find(req => req.id === id);
const eventName = eventRequest?.name || 'Event'; const eventName = eventRequest?.name || 'Event';
// Update local state // Update local state
setEventRequests(prev => setEventRequests(prev =>
prev.map(request => prev.map(request =>
request.id === id ? { ...request, status } : request request.id === id ? { ...request, flyers_completed: completed } : request
) )
); );
setFilteredRequests(prev => setFilteredRequests(prev =>
prev.map(request => prev.map(request =>
request.id === id ? { ...request, status } : request request.id === id ? { ...request, flyers_completed: completed } : request
) )
); );
// Force sync to update IndexedDB toast.success(`"${eventName}" PR status updated to ${completed ? 'completed' : 'pending'}`);
await dataSync.syncCollection<ExtendedEventRequest>(Collections.EVENT_REQUESTS);
// Send email notification if PR is completed
if (completed) {
try {
const { EmailClient } = await import('../../../scripts/email/EmailClient');
await EmailClient.notifyPRCompleted(id);
console.log('PR completion notification email sent successfully');
} catch (emailError) {
console.error('Failed to send PR completion notification email:', emailError);
// Don't show error to user - email failure shouldn't disrupt the main operation
}
}
// Show success toast with event name
toast.success(`"${eventName}" status updated to ${status}`);
} catch (error) { } catch (error) {
// Find the event request to get its name console.error('Error updating PR status:', error);
const eventRequest = eventRequests.find(req => req.id === id); toast.error('Failed to update PR status');
const eventName = eventRequest?.name || 'Event';
// console.error('Error updating status:', error);
toast.error(`Failed to update status for "${eventName}"`);
throw error; // Re-throw the error to be caught by the caller
} }
}; };
@ -234,6 +333,50 @@ const EventRequestManagementTable = ({
} }
}; };
// Format date and time range for display
const formatDateTimeRange = (startDateString: string, endDateString: string) => {
if (!startDateString) return 'Not specified';
try {
const startDate = new Date(startDateString);
const endDate = endDateString ? new Date(endDateString) : null;
const startFormatted = startDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
if (endDate && endDate.getTime() !== startDate.getTime()) {
// Check if it's the same day
const isSameDay = startDate.toDateString() === endDate.toDateString();
if (isSameDay) {
// Same day, just show end time
const endTime = endDate.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
return `${startFormatted} - ${endTime}`;
} else {
// Different day, show full end date
const endFormatted = endDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
return `${startFormatted} - ${endFormatted}`;
}
}
return startFormatted;
} catch (e) {
return startDateString;
}
};
// Get status badge class based on status // Get status badge class based on status
const getStatusBadge = (status?: "submitted" | "pending" | "completed" | "declined") => { const getStatusBadge = (status?: "submitted" | "pending" | "completed" | "declined") => {
if (!status) return 'badge-warning'; if (!status) return 'badge-warning';
@ -305,10 +448,42 @@ const EventRequestManagementTable = ({
} }
}; };
// Handle decline action with reason prompt
const handleDeclineAction = (request: ExtendedEventRequest) => {
setRequestToDecline(request);
setDeclineReason('');
setIsDeclineModalOpen(true);
};
// Confirm decline with reason
const confirmDecline = async () => {
if (!requestToDecline || !declineReason.trim()) {
toast.error('Please provide a reason for declining');
return;
}
try {
await updateEventRequestStatus(requestToDecline.id, 'declined', declineReason);
setIsDeclineModalOpen(false);
setRequestToDecline(null);
setDeclineReason('');
} catch (error) {
console.error('Error declining request:', error);
toast.error('Failed to decline request');
}
};
// Cancel decline action
const cancelDecline = () => {
setIsDeclineModalOpen(false);
setRequestToDecline(null);
setDeclineReason('');
};
// Apply filters when filter state changes // Apply filters when filter state changes
useEffect(() => { useEffect(() => {
applyFilters(); applyFilters();
}, [statusFilter, searchTerm, sortField, sortDirection]); }, [statusFilter, searchTerm, sortField, sortDirection, eventRequests]);
// Check authentication and refresh token if needed // Check authentication and refresh token if needed
useEffect(() => { useEffect(() => {
@ -450,6 +625,7 @@ const EventRequestManagementTable = ({
value={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)} onChange={(e) => setStatusFilter(e.target.value)}
> >
<option value="active">Active (Submitted & Pending)</option>
<option value="all">All Statuses</option> <option value="all">All Statuses</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="completed">Completed</option> <option value="completed">Completed</option>
@ -492,7 +668,7 @@ const EventRequestManagementTable = ({
height: "auto" height: "auto"
}} }}
> >
<table className="table table-zebra w-full"> <table className="table table-zebra w-full min-w-[600px]">
<thead className="bg-base-300/50 sticky top-0 z-10"> <thead className="bg-base-300/50 sticky top-0 z-10">
<tr> <tr>
<th <th
@ -513,7 +689,7 @@ const EventRequestManagementTable = ({
onClick={() => handleSortChange('start_date_time')} onClick={() => handleSortChange('start_date_time')}
> >
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
Date Date & Time
{sortField === 'start_date_time' && ( {sortField === 'start_date_time' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
@ -535,6 +711,19 @@ const EventRequestManagementTable = ({
</div> </div>
</th> </th>
<th className="hidden lg:table-cell">PR Materials</th> <th className="hidden lg:table-cell">PR Materials</th>
<th
className="cursor-pointer hover:bg-base-300 transition-colors hidden lg:table-cell"
onClick={() => handleSortChange('flyers_completed')}
>
<div className="flex items-center gap-1">
PR Status
{sortField === 'flyers_completed' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
</svg>
)}
</div>
</th>
<th className="hidden lg:table-cell">AS Funding</th> <th className="hidden lg:table-cell">AS Funding</th>
<th <th
className="cursor-pointer hover:bg-base-300 transition-colors hidden md:table-cell" className="cursor-pointer hover:bg-base-300 transition-colors hidden md:table-cell"
@ -562,7 +751,7 @@ const EventRequestManagementTable = ({
)} )}
</div> </div>
</th> </th>
<th>Actions</th> <th className="w-20 min-w-[5rem]">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -573,7 +762,11 @@ const EventRequestManagementTable = ({
{truncateText(request.name, 30)} {truncateText(request.name, 30)}
</div> </div>
</td> </td>
<td className="hidden md:table-cell">{formatDate(request.start_date_time)}</td> <td className="hidden md:table-cell">
<div className="text-sm">
{formatDateTimeRange(request.start_date_time, request.end_date_time)}
</div>
</td>
<td> <td>
{(() => { {(() => {
const { name, email } = getUserDisplayInfo(request); const { name, email } = getUserDisplayInfo(request);
@ -592,6 +785,28 @@ const EventRequestManagementTable = ({
<span className="badge badge-ghost badge-sm">No</span> <span className="badge badge-ghost badge-sm">No</span>
)} )}
</td> </td>
<td className="hidden lg:table-cell">
{request.flyers_needed ? (
<input
type="checkbox"
checked={request.flyers_completed || false}
onChange={(e) => {
e.stopPropagation();
updatePRStatus(request.id, e.target.checked);
}}
className="checkbox checkbox-primary"
title="Mark PR materials as completed"
/>
) : (
<input
type="checkbox"
checked={false}
disabled={true}
className="checkbox checkbox-disabled opacity-30"
title="PR materials not needed for this event"
/>
)}
</td>
<td className="hidden lg:table-cell"> <td className="hidden lg:table-cell">
{request.as_funding_required ? ( {request.as_funding_required ? (
<span className="badge badge-success badge-sm">Yes</span> <span className="badge badge-success badge-sm">Yes</span>
@ -606,16 +821,17 @@ const EventRequestManagementTable = ({
</span> </span>
</td> </td>
<td> <td>
<div className="flex items-center gap-2"> <div className="flex items-center justify-center">
<button <button
className="btn btn-sm btn-primary btn-outline btn-sm gap-2" className="btn btn-sm btn-primary btn-outline gap-1 min-h-[2rem] max-w-[5rem] flex-shrink-0"
onClick={() => openDetailModal(request)} onClick={() => openDetailModal(request)}
title="View Event Details"
> >
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg> </svg>
View <span className="hidden sm:inline">View</span>
</button> </button>
</div> </div>
</td> </td>
@ -625,6 +841,50 @@ const EventRequestManagementTable = ({
</table> </table>
</div> </div>
</motion.div> </motion.div>
{/* Decline Reason Modal */}
{isDeclineModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-base-200 rounded-lg p-6 w-full max-w-md shadow-xl"
>
<h3 className="text-lg font-semibold text-white mb-4">
Decline Event Request
</h3>
<p className="text-gray-300 mb-4">
Please provide a reason for declining "{requestToDecline?.name}". This will be sent to the submitter.
</p>
<textarea
className="textarea textarea-bordered w-full h-32 bg-base-300 text-white border-base-300 focus:border-primary"
placeholder="Enter decline reason (required)..."
value={declineReason}
onChange={(e) => setDeclineReason(e.target.value)}
maxLength={500}
/>
<div className="text-xs text-gray-400 mb-4">
{declineReason.length}/500 characters
</div>
<div className="flex justify-end gap-3">
<button
className="btn btn-ghost"
onClick={cancelDecline}
>
Cancel
</button>
<button
className="btn btn-error"
onClick={confirmDecline}
disabled={!declineReason.trim()}
>
Decline Request
</button>
</div>
</motion.div>
</div>
)}
</> </>
); );
}; };

View file

@ -3,11 +3,11 @@ import { motion, AnimatePresence } from 'framer-motion';
import EventRequestDetails from './EventRequestDetails'; import EventRequestDetails from './EventRequestDetails';
import EventRequestManagementTable from './EventRequestManagementTable'; import EventRequestManagementTable from './EventRequestManagementTable';
import { Update } from '../../../scripts/pocketbase/Update'; import { Update } from '../../../scripts/pocketbase/Update';
import { Collections, EventRequestStatus } from '../../../schemas/pocketbase/schema'; import { Collections } from '../../../schemas/pocketbase/schema';
import { DataSyncService } from '../../../scripts/database/DataSyncService'; import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Get } from '../../../scripts/pocketbase/Get'; import { Get } from '../../../scripts/pocketbase/Get';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { EmailClient } from '../../../scripts/email/EmailClient';
import type { EventRequest } from '../../../schemas/pocketbase/schema'; import type { EventRequest } from '../../../schemas/pocketbase/schema';
// Extended EventRequest interface to include expanded fields that might come from the API // Extended EventRequest interface to include expanded fields that might come from the API
@ -269,9 +269,14 @@ const EventRequestModal: React.FC<EventRequestModalProps> = ({ eventRequests })
const update = Update.getInstance(); const update = Update.getInstance();
await update.updateField("event_request", id, "status", status); await update.updateField("event_request", id, "status", status);
// Force sync to update IndexedDB // Force sync to update IndexedDB with deletion detection enabled
const dataSync = DataSyncService.getInstance(); const dataSync = DataSyncService.getInstance();
await dataSync.syncCollection(Collections.EVENT_REQUESTS); await dataSync.syncCollection(Collections.EVENT_REQUESTS, "", "-created", {}, true);
// Find the request to get its name and previous status
const request = localEventRequests.find((req) => req.id === id);
const eventName = request?.name || "Event";
const previousStatus = request?.status;
// Update local state // Update local state
setLocalEventRequests(prevRequests => setLocalEventRequests(prevRequests =>
@ -280,13 +285,18 @@ const EventRequestModal: React.FC<EventRequestModalProps> = ({ eventRequests })
) )
); );
// Find the request to get its name
const request = localEventRequests.find((req) => req.id === id);
const eventName = request?.name || "Event";
// Notify success // Notify success
toast.success(`"${eventName}" status updated to ${status}`); toast.success(`"${eventName}" status updated to ${status}`);
// Send email notification for status change (non-blocking)
try {
await EmailClient.notifyEventRequestStatusChange(id, previousStatus || 'unknown', status);
console.log('Event request status change notification email sent successfully');
} catch (emailError) {
console.error('Failed to send event request status change notification email:', emailError);
// Don't show error to user - email failure shouldn't disrupt the main operation
}
// Dispatch event for other components // Dispatch event for other components
document.dispatchEvent( document.dispatchEvent(
new CustomEvent("status-updated", { new CustomEvent("status-updated", {

View file

@ -1,15 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Authentication } from "../../../scripts/pocketbase/Authentication"; import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { Collections } from "../../../schemas/pocketbase/schema"; import { Collections } from "../../../schemas/pocketbase/schema";
import type { Event, Log, User } from "../../../schemas/pocketbase"; import type { Event, User, LimitedUser } from "../../../schemas/pocketbase";
import { Get } from "../../../scripts/pocketbase/Get"; import { Get } from "../../../scripts/pocketbase/Get";
import type { EventAttendee } from "../../../schemas/pocketbase"; import type { EventAttendee } from "../../../schemas/pocketbase";
import { Update } from "../../../scripts/pocketbase/Update"; import { Update } from "../../../scripts/pocketbase/Update";
// Extended User interface with points property // Extended User interface with member_type property
interface ExtendedUser extends User { interface ExtendedUser extends User {
points?: number;
member_type?: string; member_type?: string;
} }
@ -84,25 +82,13 @@ export function Stats() {
setEventsAttended(attendedEvents.totalItems); setEventsAttended(attendedEvents.totalItems);
// Get user points - either from the user record or calculate from attendees // Calculate points from attendees
let totalPoints = 0; let totalPoints = 0;
// Calculate quarterly points // Calculate quarterly points
const quarterStartDate = getCurrentQuarterStartDate(); const quarterStartDate = getCurrentQuarterStartDate();
let pointsThisQuarter = 0; let pointsThisQuarter = 0;
// If user has points field, use that for total points
if (currentUser && currentUser.points !== undefined) {
totalPoints = currentUser.points;
// Still need to calculate quarterly points from attendees
attendedEvents.items.forEach(attendee => {
const checkinDate = new Date(attendee.time_checked_in);
if (checkinDate >= quarterStartDate) {
pointsThisQuarter += attendee.points_earned || 0;
}
});
} else {
// Calculate both total and quarterly points from attendees // Calculate both total and quarterly points from attendees
attendedEvents.items.forEach(attendee => { attendedEvents.items.forEach(attendee => {
const points = attendee.points_earned || 0; const points = attendee.points_earned || 0;
@ -114,17 +100,26 @@ export function Stats() {
} }
}); });
// Update the user record with calculated points if needed // Try to get the LimitedUser record to check if points match
if (currentUser) {
try { try {
const update = Update.getInstance(); const limitedUserRecord = await get.getOne(
await update.updateFields(Collections.USERS, currentUser.id, { Collections.LIMITED_USERS,
points: totalPoints userId
}); );
} catch (error) {
console.error("Error updating user points:", error); if (limitedUserRecord && limitedUserRecord.points) {
try {
// Parse the points JSON string
const parsedPoints = JSON.parse(limitedUserRecord.points);
if (parsedPoints !== totalPoints) {
console.log(`Points mismatch: LimitedUser has ${parsedPoints}, calculated ${totalPoints}`);
}
} catch (e) {
console.error('Error parsing points from LimitedUser:', e);
} }
} }
} catch (e) {
// LimitedUser record might not exist yet, that's okay
} }
setPointsEarned(totalPoints); setPointsEarned(totalPoints);
@ -199,7 +194,7 @@ export function Stats() {
</div> </div>
<div className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-secondary transition-all duration-300 hover:-translate-y-1 transform"> <div className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-secondary transition-all duration-300 hover:-translate-y-1 transform">
<div className="stat"> <div className="stat">
<div className="stat-title font-medium opacity-80">Loyalty Points</div> <div className="stat-title font-medium opacity-80">Points</div>
<div className="stat-value text-secondary">{loyaltyPoints}</div> <div className="stat-value text-secondary">{loyaltyPoints}</div>
<div className="stat-desc flex flex-col items-start gap-1 mt-1"> <div className="stat-desc flex flex-col items-start gap-1 mt-1">
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">

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,194 @@
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;
}
function getResumeUrl(user: ResumeUser): string | undefined {
if (!user.resume) return undefined;
return `https://pocketbase.ieeeucsd.org/api/files/users/${user.id}/${user.resume}`;
}
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={getResumeUrl(user)}
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={`${getResumeUrl(user)}#toolbar=0&navpanes=0`}
className="w-full h-full border-0"
title={`${user.name}'s Resume`}
/>
</div>
) : user.resume && user.resume.toLowerCase().endsWith('.docx') ? (
<div className="aspect-[8.5/11] w-full">
<iframe
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(getResumeUrl(user) ?? '')}`}
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

@ -5,7 +5,6 @@ import AccountSecuritySettings from "./SettingsSection/AccountSecuritySettings";
import NotificationSettings from "./SettingsSection/NotificationSettings"; import NotificationSettings from "./SettingsSection/NotificationSettings";
import DisplaySettings from "./SettingsSection/DisplaySettings"; import DisplaySettings from "./SettingsSection/DisplaySettings";
import ResumeSettings from "./SettingsSection/ResumeSettings"; import ResumeSettings from "./SettingsSection/ResumeSettings";
import EmailRequestSettings from "./SettingsSection/EmailRequestSettings";
import ThemeToggle from "./universal/ThemeToggle"; import ThemeToggle from "./universal/ThemeToggle";
// Import environment variables // Import environment variables
@ -131,27 +130,6 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
</div> </div>
</div> </div>
<!-- IEEE Email Request Card -->
<div
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
>
<div class="card-body">
<h3 class="card-title flex items-center gap-3">
<div
class="inline-flex items-center justify-center p-3 rounded-full bg-primary text-primary-foreground"
>
<Icon name="heroicons:envelope" class="h-5 w-5" />
</div>
IEEE Email Address
</h3>
<p class="text-sm opacity-70 mb-4">
Request an official IEEE UCSD email address (officers only)
</p>
<div class="h-px w-full bg-border my-4"></div>
<EmailRequestSettings client:load />
</div>
</div>
<!-- Account Security Settings Card --> <!-- Account Security Settings Card -->
<div <div
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6" class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
@ -215,13 +193,11 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
<!-- Display Settings Card --> <!-- Display Settings Card -->
<div <div
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform" class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform"
> >
<div class="card-body"> <div class="card-body">
<h3 class="card-title flex items-center gap-3"> <h3 class="card-title flex items-center gap-3">
<div <div class="badge badge-primary p-3">
class="inline-flex items-center justify-center p-3 rounded-full bg-primary text-primary-foreground"
>
<Icon name="heroicons:computer-desktop" class="h-5 w-5" /> <Icon name="heroicons:computer-desktop" class="h-5 w-5" />
</div> </div>
Display Settings Display Settings
@ -229,18 +205,14 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
<p class="text-sm opacity-70 mb-4"> <p class="text-sm opacity-70 mb-4">
Customize your dashboard appearance and display preferences Customize your dashboard appearance and display preferences
</p> </p>
<div class="h-px w-full bg-border my-4"></div> <div class="divider"></div>
<div <div class="alert alert-warning mb-4">
class="flex p-4 mb-4 text-sm rounded-lg bg-warning/20 text-warning-foreground"
role="alert"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="flex-shrink-0 w-5 h-5 mr-3" class="stroke-current shrink-0 h-6 w-6"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"
><path ><path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"

View file

@ -59,16 +59,7 @@ export default function AccountSecuritySettings({
checkAuth(); checkAuth();
}, []); }, []);
const handleLogout = async () => { // No logout functions needed here as logout is handled in the dashboard menu
try {
await logger.send('logout', 'auth', 'User manually logged out from settings page');
await auth.logout();
window.location.href = '/';
} catch (error) {
console.error('Error during logout:', error);
toast.error('Failed to log out. Please try again.');
}
};
const detectBrowser = (userAgent: string): string => { const detectBrowser = (userAgent: string): string => {
if (userAgent.indexOf('Chrome') > -1) return 'Chrome'; if (userAgent.indexOf('Chrome') > -1) return 'Chrome';
@ -179,17 +170,13 @@ export default function AccountSecuritySettings({
<h4 className="font-semibold text-lg mb-2">Account Actions</h4> <h4 className="font-semibold text-lg mb-2">Account Actions</h4>
<div className="space-y-4"> <div className="space-y-4">
<button
onClick={handleLogout}
className="btn btn-error btn-outline w-full md:w-auto"
>
Sign Out
</button>
<p className="text-sm text-warning p-3 bg-warning bg-opacity-10 rounded-lg"> <p className="text-sm text-warning p-3 bg-warning bg-opacity-10 rounded-lg">
If you need to delete your account or have other account-related issues, If you need to delete your account or have other account-related issues,
please contact an IEEE UCSD administrator. please contact an IEEE UCSD administrator.
</p> </p>
<p className="text-sm text-info p-3 bg-info bg-opacity-10 rounded-lg">
To log out of your account, use the Logout option in the dashboard menu.
</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -258,17 +258,17 @@ export default function DisplaySettings() {
{/* Theme Settings */} {/* Theme Settings */}
<div> <div>
<h4 className="font-semibold text-lg mb-2">Theme</h4> <h4 className="font-semibold text-lg mb-2">Theme</h4>
<div className="w-full max-w-xs"> <div className="form-control w-full max-w-xs">
<select <select
value={theme} value={theme}
onChange={handleThemeChange} onChange={handleThemeChange}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="select select-bordered"
> >
<option value="light">Light</option> <option value="light">Light</option>
<option value="dark">Dark</option> <option value="dark">Dark</option>
</select> </select>
<label className="mt-1 block"> <label className="label">
<span className="text-xs text-muted-foreground">Select your preferred theme</span> <span className="label-text-alt">Select your preferred theme</span>
</label> </label>
</div> </div>
</div> </div>
@ -276,19 +276,19 @@ export default function DisplaySettings() {
{/* Font Size Settings */} {/* Font Size Settings */}
<div> <div>
<h4 className="font-semibold text-lg mb-2">Font Size</h4> <h4 className="font-semibold text-lg mb-2">Font Size</h4>
<div className="w-full max-w-xs"> <div className="form-control w-full max-w-xs">
<select <select
value={fontSize} value={fontSize}
onChange={handleFontSizeChange} onChange={handleFontSizeChange}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="select select-bordered"
> >
<option value="small">Small</option> <option value="small">Small</option>
<option value="medium">Medium</option> <option value="medium">Medium</option>
<option value="large">Large</option> <option value="large">Large</option>
<option value="extra-large">Extra Large</option> <option value="extra-large">Extra Large</option>
</select> </select>
<label className="mt-1 block"> <label className="label">
<span className="text-xs text-muted-foreground">Select your preferred font size</span> <span className="label-text-alt">Select your preferred font size</span>
</label> </label>
</div> </div>
</div> </div>
@ -297,64 +297,54 @@ export default function DisplaySettings() {
<div> <div>
<h4 className="font-semibold text-lg mb-2">Accessibility</h4> <h4 className="font-semibold text-lg mb-2">Accessibility</h4>
<div className="flex items-center space-x-4 mb-4"> <div className="form-control">
<label className="relative inline-flex items-center cursor-pointer"> <label className="cursor-pointer label justify-start gap-4">
<input <input
type="checkbox" type="checkbox"
checked={colorBlindMode} checked={colorBlindMode}
onChange={handleColorBlindModeChange} onChange={handleColorBlindModeChange}
className="sr-only peer" className="toggle toggle-primary"
/> />
<div className="w-11 h-6 bg-muted rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
<div> <div>
<span className="font-medium">Color Blind Mode</span> <span className="label-text font-medium">Color Blind Mode</span>
<p className="text-xs text-muted-foreground">Enhances color contrast and uses color-blind friendly palettes</p> <p className="text-xs opacity-70">Enhances color contrast and uses color-blind friendly palettes</p>
</div> </div>
</label>
</div> </div>
<div className="flex items-center space-x-4"> <div className="form-control mt-2">
<label className="relative inline-flex items-center cursor-pointer"> <label className="cursor-pointer label justify-start gap-4">
<input <input
type="checkbox" type="checkbox"
checked={reducedMotion} checked={reducedMotion}
onChange={handleReducedMotionChange} onChange={handleReducedMotionChange}
className="sr-only peer" className="toggle toggle-primary"
/> />
<div className="w-11 h-6 bg-muted rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
<div> <div>
<span className="font-medium">Reduced Motion</span> <span className="label-text font-medium">Reduced Motion</span>
<p className="text-xs text-muted-foreground">Minimizes animations and transitions</p> <p className="text-xs opacity-70">Minimizes animations and transitions</p>
</div> </div>
</label>
</div> </div>
</div> </div>
<p className="text-sm text-blue-500 dark:text-blue-400 mt-4"> <p className="text-sm text-info">
These settings are saved to your browser using IndexedDB and your IEEE UCSD account. They will be applied whenever you log in. These settings are saved to your browser using IndexedDB and your IEEE UCSD account. They will be applied whenever you log in.
</p> </p>
<div className="mt-4"> <div className="form-control">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{hasChanges && ( {hasChanges && (
<p className="text-sm text-amber-600 dark:text-amber-400"> <p className="text-sm text-warning">
You have unsaved changes. Click "Save Settings" to apply them. You have unsaved changes. Click "Save Settings" to apply them.
</p> </p>
)} )}
<button <button
type="submit" type="submit"
className={`inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 ${saving ? 'opacity-70' : ''}`} className={`btn btn-primary ${saving ? 'loading' : ''}`}
disabled={saving || !hasChanges} disabled={saving || !hasChanges}
> >
{saving ? ( {saving ? 'Saving...' : 'Save Settings'}
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Saving...
</>
) : 'Save Settings'}
</button> </button>
</div> </div>
</div> </div>

View file

@ -370,7 +370,7 @@ export default function EmailRequestSettings() {
<div className="p-4 bg-base-200 rounded-lg"> <div className="p-4 bg-base-200 rounded-lg">
<p className="text-sm"> <p className="text-sm">
If you have any questions or need help with your IEEE email, please contact <a href="mailto:webmaster@ieeeucsd.org" className="underline">webmaster@ieeeucsd.org</a> If you have any questions or need help with your IEEE email, please contact <a href="mailto:webmaster@ieeeatucsd.org" className="underline">webmaster@ieeeatucsd.org</a>
</p> </p>
</div> </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,205 @@
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: 'Social', key: 'social', color: 'rgba(255, 99, 132, 0.8)' },
{ name: 'Technical', key: 'technical', color: 'rgba(54, 162, 235, 0.8)' },
{ name: 'Outreach', key: 'outreach', color: 'rgba(255, 206, 86, 0.8)' },
{ name: 'Professional', key: 'professional', color: 'rgba(75, 192, 192, 0.8)' },
{ name: 'Projects', key: 'projects', color: 'rgba(153, 102, 255, 0.8)' },
{ name: 'Other', key: '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>);
// Count events by event_type field from schema
events.forEach(event => {
const type = event.event_type && EVENT_TYPES.find(t => t.key === event.event_type) ? event.event_type : 'other';
const typeObj = EVENT_TYPES.find(t => t.key === type);
if (typeObj) {
eventTypeCount[typeObj.name]++;
}
});
// 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) {
// Only show the label, not the value
return context.label || '';
}
}
}
},
};
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

@ -4,6 +4,7 @@ import FilePreview from '../universal/FilePreview';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import type { ItemizedExpense } from '../../../schemas/pocketbase'; import type { ItemizedExpense } from '../../../schemas/pocketbase';
// import ZoomablePreview from '../universal/ZoomablePreview';
interface ReceiptFormData { interface ReceiptFormData {
file: File; file: File;
@ -66,6 +67,35 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
const [locationAddress, setLocationAddress] = useState<string>(''); const [locationAddress, setLocationAddress] = useState<string>('');
const [notes, setNotes] = useState<string>(''); const [notes, setNotes] = useState<string>('');
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [jsonInput, setJsonInput] = useState<string>('');
const [showJsonInput, setShowJsonInput] = useState<boolean>(false);
const [zoomLevel, setZoomLevel] = useState<number>(1);
// Sample JSON data for users to copy
const sampleJsonData = {
itemized_expenses: [
{
description: "Presentation supplies for IEEE workshop",
category: "Supplies",
amount: 45.99
},
{
description: "Team lunch during planning meeting",
category: "Meals",
amount: 82.50
},
{
description: "Transportation to conference venue",
category: "Travel",
amount: 28.75
}
],
tax: 12.65,
date: "2024-01-15",
location_name: "Office Depot & Local Restaurant",
location_address: "1234 Campus Drive, San Diego, CA 92093",
notes: "Expenses for January IEEE workshop preparation and team coordination meeting"
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) { if (e.target.files && e.target.files[0]) {
@ -144,6 +174,69 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
}); });
}; };
const parseJsonData = () => {
try {
if (!jsonInput.trim()) {
toast.error('Please enter JSON data to parse');
return;
}
const parsed = JSON.parse(jsonInput);
// Validate the structure
if (!parsed.itemized_expenses || !Array.isArray(parsed.itemized_expenses)) {
throw new Error('itemized_expenses must be an array');
}
// Validate each expense item
for (const item of parsed.itemized_expenses) {
if (!item.description || !item.category || typeof item.amount !== 'number') {
throw new Error('Each expense item must have description, category, and amount');
}
if (!EXPENSE_CATEGORIES.includes(item.category)) {
throw new Error(`Invalid category: ${item.category}. Must be one of: ${EXPENSE_CATEGORIES.join(', ')}`);
}
}
// Populate the form fields
setItemizedExpenses(parsed.itemized_expenses);
if (parsed.tax !== undefined) setTax(Number(parsed.tax) || 0);
if (parsed.date) setDate(parsed.date);
if (parsed.location_name) setLocationName(parsed.location_name);
if (parsed.location_address) setLocationAddress(parsed.location_address);
if (parsed.notes) setNotes(parsed.notes);
setError('');
toast.success(`Successfully imported ${parsed.itemized_expenses.length} expense items`);
setShowJsonInput(false);
setJsonInput('');
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Invalid JSON format';
setError(`JSON Parse Error: ${errorMessage}`);
toast.error(`Failed to parse JSON: ${errorMessage}`);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
toast.success('Sample data copied to clipboard!');
}).catch(() => {
toast.error('Failed to copy to clipboard');
});
};
const zoomIn = () => {
setZoomLevel(prev => Math.min(prev + 0.25, 3));
};
const zoomOut = () => {
setZoomLevel(prev => Math.max(prev - 0.25, 0.5));
};
const resetZoom = () => {
setZoomLevel(1);
};
return ( return (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@ -155,7 +248,11 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
animate="visible" animate="visible"
className="space-y-4 overflow-y-auto max-h-[70vh] pr-4 custom-scrollbar" className="space-y-4 overflow-y-auto max-h-[70vh] pr-8 overflow-x-hidden"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(156, 163, 175, 0.5) transparent'
}}
> >
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
@ -191,8 +288,9 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
</div> </div>
</motion.div> </motion.div>
{/* Date */} {/* Date and Location in Grid */}
<motion.div variants={itemVariants} className="form-control"> <motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"> <label className="label">
<span className="label-text font-medium">Date</span> <span className="label-text font-medium">Date</span>
<span className="label-text-alt text-error">*</span> <span className="label-text-alt text-error">*</span>
@ -204,10 +302,27 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
onChange={(e) => setDate(e.target.value)} onChange={(e) => setDate(e.target.value)}
required required
/> />
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Tax Amount ($)</span>
</label>
<input
type="number"
className="input input-bordered focus:input-primary transition-all duration-300"
value={tax === 0 ? '' : tax}
onChange={(e) => setTax(Number(e.target.value))}
min="0"
step="0.01"
placeholder="0.00"
/>
</div>
</motion.div> </motion.div>
{/* Location Name */} {/* Location Fields */}
<motion.div variants={itemVariants} className="form-control"> <motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label"> <label className="label">
<span className="label-text font-medium">Location Name</span> <span className="label-text font-medium">Location Name</span>
<span className="label-text-alt text-error">*</span> <span className="label-text-alt text-error">*</span>
@ -217,12 +332,12 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
className="input input-bordered focus:input-primary transition-all duration-300" className="input input-bordered focus:input-primary transition-all duration-300"
value={locationName} value={locationName}
onChange={(e) => setLocationName(e.target.value)} onChange={(e) => setLocationName(e.target.value)}
placeholder="Store/vendor name"
required required
/> />
</motion.div> </div>
{/* Location Address */} <div className="form-control">
<motion.div variants={itemVariants} className="form-control">
<label className="label"> <label className="label">
<span className="label-text font-medium">Location Address</span> <span className="label-text font-medium">Location Address</span>
<span className="label-text-alt text-error">*</span> <span className="label-text-alt text-error">*</span>
@ -232,37 +347,124 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
className="input input-bordered focus:input-primary transition-all duration-300" className="input input-bordered focus:input-primary transition-all duration-300"
value={locationAddress} value={locationAddress}
onChange={(e) => setLocationAddress(e.target.value)} onChange={(e) => setLocationAddress(e.target.value)}
placeholder="Full address"
required required
/> />
</div>
</motion.div> </motion.div>
{/* Notes */} {/* Notes - Reduced height */}
<motion.div variants={itemVariants} className="form-control"> <motion.div variants={itemVariants} className="form-control">
<label className="label"> <label className="label">
<span className="label-text font-medium">Notes</span> <span className="label-text font-medium">Notes</span>
</label> </label>
<textarea <textarea
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[100px]" className="textarea textarea-bordered focus:textarea-primary transition-all duration-300"
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
rows={3} rows={2}
placeholder="Additional notes..."
/> />
</motion.div> </motion.div>
{/* JSON Import Section */}
<motion.div variants={itemVariants} className="space-y-4">
<div className="card bg-base-200/30 border border-primary/20 shadow-sm">
<div className="card-body p-4">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium text-primary">Quick Import from JSON</h3>
<p className="text-sm text-base-content/70">Paste receipt data in JSON format to auto-populate fields</p>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
className="btn btn-primary btn-sm gap-2"
onClick={() => setShowJsonInput(!showJsonInput)}
>
<Icon icon={showJsonInput ? "heroicons:chevron-up" : "heroicons:chevron-down"} className="h-4 w-4" />
{showJsonInput ? 'Hide' : 'Show'} JSON Import
</motion.button>
</div>
<AnimatePresence>
{showJsonInput && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="space-y-4 mt-4 overflow-hidden"
>
{/* Sample Data Section */}
<div className="bg-base-100/50 rounded-lg p-4 border border-base-300/50">
<div className="flex justify-between items-center mb-3">
<h4 className="font-medium text-sm">Sample JSON Format:</h4>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
className="btn btn-xs btn-ghost gap-1"
onClick={() => copyToClipboard(JSON.stringify(sampleJsonData, null, 2))}
>
<Icon icon="heroicons:clipboard-document" className="h-3 w-3" />
Copy Sample
</motion.button>
</div>
<pre className="text-xs bg-base-200/50 p-3 rounded border overflow-x-auto">
<code>{JSON.stringify(sampleJsonData, null, 2)}</code>
</pre>
<div className="mt-2 text-xs text-base-content/60">
<p><strong>Required fields:</strong> itemized_expenses (array)</p>
<p><strong>Optional fields:</strong> tax, date, location_name, location_address, notes</p>
<p><strong>Valid categories:</strong> {EXPENSE_CATEGORIES.join(', ')}</p>
</div>
</div>
{/* JSON Input Area */}
<div className="space-y-3">
<label className="label">
<span className="label-text font-medium">Paste your JSON data:</span>
</label>
<textarea
className="textarea textarea-bordered w-full min-h-[150px] font-mono text-sm"
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
placeholder="Paste your JSON data here..."
/>
<div className="flex justify-end gap-2">
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setJsonInput('')}
>
Clear
</motion.button>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
type="button"
className="btn btn-primary btn-sm gap-2"
onClick={parseJsonData}
>
<Icon icon="heroicons:arrow-down-tray" className="h-4 w-4" />
Import Data
</motion.button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
{/* Itemized Expenses */} {/* Itemized Expenses */}
<motion.div variants={itemVariants} className="space-y-4"> <motion.div variants={itemVariants} className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<label className="text-lg font-medium">Itemized Expenses</label> <label className="text-lg font-medium">Itemized Expenses</label>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
className="btn btn-primary btn-sm gap-2 hover:shadow-lg transition-all duration-300"
onClick={addExpenseItem}
>
<Icon icon="heroicons:plus" className="h-4 w-4" />
Add Item
</motion.button>
</div> </div>
<AnimatePresence> <AnimatePresence>
@ -274,33 +476,48 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
exit={{ opacity: 0, x: 20 }} exit={{ opacity: 0, x: 20 }}
className="card bg-base-200/50 hover:bg-base-200 transition-colors duration-300 backdrop-blur-sm shadow-sm" className="card bg-base-200/50 hover:bg-base-200 transition-colors duration-300 backdrop-blur-sm shadow-sm"
> >
<div className="card-body p-4"> <div className="card-body p-3">
<div className="grid gap-4"> <div className="flex justify-between items-center mb-2">
<h4 className="text-sm font-medium">Item #{index + 1}</h4>
{itemizedExpenses.length > 1 && (
<button
type="button"
className="btn btn-xs btn-ghost text-error hover:bg-error/10"
onClick={() => removeExpenseItem(index)}
aria-label="Remove item"
>
<Icon icon="heroicons:trash" className="h-3 w-3" />
</button>
)}
</div>
<div className="grid gap-3">
<div className="form-control"> <div className="form-control">
<label className="label"> <label className="label py-1">
<span className="label-text">Description</span> <span className="label-text text-xs">Description</span>
</label> </label>
<input <input
type="text" type="text"
className="input input-bordered" className="input input-bordered input-sm"
value={item.description} value={item.description}
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)} onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
placeholder="What was purchased?"
required required
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-3">
<div className="form-control"> <div className="form-control">
<label className="label"> <label className="label py-1">
<span className="label-text">Category</span> <span className="label-text text-xs">Category</span>
</label> </label>
<select <select
className="select select-bordered" className="select select-bordered select-sm w-full"
value={item.category} value={item.category}
onChange={(e) => handleExpenseItemChange(index, 'category', e.target.value)} onChange={(e) => handleExpenseItemChange(index, 'category', e.target.value)}
required required
> >
<option value="">Select category</option> <option value="">Select...</option>
{EXPENSE_CATEGORIES.map(category => ( {EXPENSE_CATEGORIES.map(category => (
<option key={category} value={category}>{category}</option> <option key={category} value={category}>{category}</option>
))} ))}
@ -308,29 +525,19 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
</div> </div>
<div className="form-control"> <div className="form-control">
<label className="label"> <label className="label py-1">
<span className="label-text">Amount ($)</span> <span className="label-text text-xs">Amount ($)</span>
</label> </label>
<div className="flex items-center space-x-2">
<input <input
type="number" type="number"
className="input input-bordered" className="input input-bordered input-sm w-full"
value={item.amount} value={item.amount === 0 ? '' : item.amount}
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))} onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))}
min="0" min="0"
step="0.01" step="0.01"
placeholder="0.00"
required required
/> />
{itemizedExpenses.length > 1 && (
<button
type="button"
className="btn btn-square btn-sm btn-error"
onClick={() => removeExpenseItem(index)}
>
<Icon icon="heroicons:trash" className="h-4 w-4" />
</button>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -338,38 +545,41 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
</motion.div> </motion.div>
))} ))}
</AnimatePresence> </AnimatePresence>
</motion.div>
{/* Tax */} {/* Add Item Button - Moved to bottom */}
<motion.div variants={itemVariants} className="form-control"> <motion.div
<label className="label"> initial={{ opacity: 0, y: 10 }}
<span className="label-text font-medium">Tax Amount ($)</span> animate={{ opacity: 1, y: 0 }}
</label> className="flex justify-center pt-2"
<input >
type="number" <motion.button
className="input input-bordered focus:input-primary transition-all duration-300" whileHover={{ scale: 1.05 }}
value={tax} whileTap={{ scale: 0.95 }}
onChange={(e) => setTax(Number(e.target.value))} type="button"
min="0" className="btn btn-primary btn-sm gap-2 hover:shadow-lg transition-all duration-300"
step="0.01" onClick={addExpenseItem}
/> >
<Icon icon="heroicons:plus" className="h-4 w-4" />
Add Item
</motion.button>
</motion.div>
</motion.div> </motion.div>
{/* Total */} {/* Total */}
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm"> <motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-3 shadow-sm">
<div className="space-y-2"> <div className="space-y-1">
<div className="flex justify-between items-center text-base-content/70"> <div className="flex justify-between items-center text-sm text-base-content/70">
<span>Subtotal:</span> <span>Subtotal:</span>
<span className="font-mono">${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)}</span> <span className="font-mono">${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)}</span>
</div> </div>
<div className="flex justify-between items-center text-base-content/70"> <div className="flex justify-between items-center text-sm text-base-content/70">
<span>Tax:</span> <span>Tax:</span>
<span className="font-mono">${tax.toFixed(2)}</span> <span className="font-mono">${tax.toFixed(2)}</span>
</div> </div>
<div className="divider my-1"></div> <div className="divider my-1"></div>
<div className="flex justify-between items-center font-medium text-lg"> <div className="flex justify-between items-center font-medium">
<span>Total:</span> <span>Total:</span>
<span className="font-mono text-primary">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span> <span className="font-mono text-primary text-lg">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span>
</div> </div>
</div> </div>
</motion.div> </motion.div>
@ -412,13 +622,60 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }} exit={{ opacity: 0, scale: 0.9 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }} transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="bg-base-200/50 backdrop-blur-sm rounded-xl p-4 shadow-sm" className="bg-base-200/50 backdrop-blur-sm rounded-xl shadow-sm relative"
> >
<FilePreview {/* Zoom Controls */}
url={previewUrl} <div className="absolute top-4 right-4 z-10 flex flex-col gap-2 bg-base-100/90 backdrop-blur-sm rounded-lg p-2 shadow-lg">
filename={file?.name || ''} <motion.button
isModal={false} whileHover={{ scale: 1.1 }}
/> whileTap={{ scale: 0.9 }}
className="btn btn-xs btn-ghost"
onClick={zoomIn}
disabled={zoomLevel >= 3}
title="Zoom In"
>
<Icon icon="heroicons:plus" className="h-3 w-3" />
</motion.button>
<div className="text-xs text-center font-mono px-1">
{Math.round(zoomLevel * 100)}%
</div>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="btn btn-xs btn-ghost"
onClick={zoomOut}
disabled={zoomLevel <= 0.5}
title="Zoom Out"
>
<Icon icon="heroicons:minus" className="h-3 w-3" />
</motion.button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="btn btn-xs btn-ghost"
onClick={resetZoom}
disabled={zoomLevel === 1}
title="Reset Zoom"
>
<Icon icon="heroicons:arrows-pointing-out" className="h-3 w-3" />
</motion.button>
</div>
{/* Preview with Zoom */}
<div
className="overflow-auto h-full rounded-xl"
style={{
transform: `scale(${zoomLevel})`,
transformOrigin: 'top left',
height: zoomLevel > 1 ? `${100 / zoomLevel}%` : '100%',
width: zoomLevel > 1 ? `${100 / zoomLevel}%` : '100%'
}}
>
<FilePreview url={previewUrl} filename={file?.name || ''} />
</div>
</motion.div> </motion.div>
) : ( ) : (
<motion.div <motion.div

View file

@ -1,13 +1,14 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect } from 'react';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { DataSyncService } from '../../../scripts/database/DataSyncService'; import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema'; import { Collections } from '../../../schemas/pocketbase/schema';
import { EmailClient } from '../../../scripts/email/EmailClient';
import ReceiptForm from './ReceiptForm'; import ReceiptForm from './ReceiptForm';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import FilePreview from '../universal/FilePreview'; import FilePreview from '../universal/FilePreview';
import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase'; import type { ItemizedExpense, Reimbursement } from '../../../schemas/pocketbase';
interface ReceiptFormData { interface ReceiptFormData {
file: File; file: File;
@ -277,11 +278,34 @@ export default function ReimbursementForm() {
formData.append('receipts', JSON.stringify(request.receipts)); formData.append('receipts', JSON.stringify(request.receipts));
formData.append('department', request.department); formData.append('department', request.department);
await pb.collection('reimbursement').create(formData); // Create the reimbursement record
const newReimbursement = await pb.collection('reimbursement').create(formData);
// Sync the reimbursements collection to update IndexedDB // Sync the reimbursements collection to update IndexedDB
const dataSync = DataSyncService.getInstance(); const dataSync = DataSyncService.getInstance();
// Force sync with specific filter to ensure the new record is fetched
await dataSync.syncCollection(
Collections.REIMBURSEMENTS,
`submitted_by="${userId}"`,
'-created',
'audit_notes'
);
// Verify the new record is in IndexedDB
const syncedData = await dataSync.getData(
Collections.REIMBURSEMENTS,
true, // Force sync again to be sure
`id="${newReimbursement.id}"`
);
if (syncedData.length === 0) {
console.warn('New reimbursement not found in IndexedDB after sync, forcing another sync');
// Try one more time with a slight delay
setTimeout(async () => {
await dataSync.syncCollection(Collections.REIMBURSEMENTS); await dataSync.syncCollection(Collections.REIMBURSEMENTS);
}, 500);
}
// Reset form // Reset form
setRequest({ setRequest({
@ -308,6 +332,14 @@ export default function ReimbursementForm() {
} }
}); });
// Send email notification
try {
await EmailClient.notifySubmission(newReimbursement.id);
} catch (emailError) {
console.error('Failed to send submission email notification:', emailError);
// Don't fail the entire operation if email fails
}
} catch (error) { } catch (error) {
console.error('Error submitting reimbursement request:', error); console.error('Error submitting reimbursement request:', error);
toast.error('Failed to submit reimbursement request. Please try again.'); toast.error('Failed to submit reimbursement request. Please try again.');

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { Get } from '../../../scripts/pocketbase/Get'; import { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Authentication } from '../../../scripts/pocketbase/Authentication';
@ -114,6 +114,27 @@ export default function ReimbursementList() {
useEffect(() => { useEffect(() => {
// console.log('Component mounted'); // console.log('Component mounted');
fetchReimbursements(); fetchReimbursements();
// Set up an interval to refresh the reimbursements list periodically
const refreshInterval = setInterval(() => {
if (document.visibilityState === 'visible') {
fetchReimbursements();
}
}, 30000); // Refresh every 30 seconds when tab is visible
// Listen for visibility changes to refresh when user returns to the tab
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
fetchReimbursements();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
clearInterval(refreshInterval);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []); }, []);
// Add effect to monitor requests state // Add effect to monitor requests state
@ -156,7 +177,7 @@ export default function ReimbursementList() {
// Use DataSyncService to get data from IndexedDB with forced sync // Use DataSyncService to get data from IndexedDB with forced sync
const dataSync = DataSyncService.getInstance(); const dataSync = DataSyncService.getInstance();
// Sync reimbursements collection // Sync reimbursements collection with force sync
await dataSync.syncCollection( await dataSync.syncCollection(
Collections.REIMBURSEMENTS, Collections.REIMBURSEMENTS,
`submitted_by="${userId}"`, `submitted_by="${userId}"`,
@ -164,10 +185,10 @@ export default function ReimbursementList() {
'audit_notes' 'audit_notes'
); );
// Get reimbursements from IndexedDB // Get reimbursements from IndexedDB with forced sync to ensure latest data
const reimbursementRecords = await dataSync.getData<ReimbursementRequest>( const reimbursementRecords = await dataSync.getData<ReimbursementRequest>(
Collections.REIMBURSEMENTS, Collections.REIMBURSEMENTS,
false, // Don't force sync again true, // Force sync to ensure we have the latest data
`submitted_by="${userId}"`, `submitted_by="${userId}"`,
'-created' '-created'
); );

View file

@ -5,6 +5,7 @@ import { Get } from '../../../scripts/pocketbase/Get';
import { Update } from '../../../scripts/pocketbase/Update'; import { Update } from '../../../scripts/pocketbase/Update';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { FileManager } from '../../../scripts/pocketbase/FileManager'; import { FileManager } from '../../../scripts/pocketbase/FileManager';
import { EmailClient } from '../../../scripts/email/EmailClient';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import type { Receipt as SchemaReceipt, User, Reimbursement } from '../../../schemas/pocketbase'; import type { Receipt as SchemaReceipt, User, Reimbursement } from '../../../schemas/pocketbase';
@ -32,6 +33,10 @@ interface FilterOptions {
dateRange: 'all' | 'week' | 'month' | 'year'; dateRange: 'all' | 'week' | 'month' | 'year';
sortBy: 'date_of_purchase' | 'total_amount' | 'status'; sortBy: 'date_of_purchase' | 'total_amount' | 'status';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
hidePaid: boolean; // Auto-hide paid reimbursements
hideRejected: boolean; // Auto-hide rejected reimbursements
compactView: boolean; // Toggle for compact list view
search: string; // Search query
} }
interface ItemizedExpense { interface ItemizedExpense {
@ -53,7 +58,11 @@ export default function ReimbursementManagementPortal() {
department: [], department: [],
dateRange: 'all', dateRange: 'all',
sortBy: 'date_of_purchase', sortBy: 'date_of_purchase',
sortOrder: 'desc' sortOrder: 'desc',
hidePaid: true,
hideRejected: true,
compactView: false,
search: ''
}); });
const [auditNote, setAuditNote] = useState(''); const [auditNote, setAuditNote] = useState('');
const [loadingStatus, setLoadingStatus] = useState(false); const [loadingStatus, setLoadingStatus] = useState(false);
@ -110,6 +119,21 @@ export default function ReimbursementManagementPortal() {
filter = `(${statusFilter})`; filter = `(${statusFilter})`;
} }
// When searching, don't auto-hide paid/rejected unless explicitly filtered
const isSearching = filters.search.trim().length > 0;
// Auto-hide paid reimbursements if the option is enabled and not searching
if (filters.hidePaid && !isSearching) {
const hidePaidFilter = 'status != "paid"';
filter = filter ? `${filter} && ${hidePaidFilter}` : hidePaidFilter;
}
// Auto-hide rejected reimbursements if the option is enabled and not searching
if (filters.hideRejected && !isSearching) {
const hideRejectedFilter = 'status != "rejected"';
filter = filter ? `${filter} && ${hideRejectedFilter}` : hideRejectedFilter;
}
if (filters.department.length > 0) { if (filters.department.length > 0) {
const departmentFilter = filters.department.map(d => `department = "${d}"`).join(' || '); const departmentFilter = filters.department.map(d => `department = "${d}"`).join(' || ');
filter = filter ? `${filter} && (${departmentFilter})` : `(${departmentFilter})`; filter = filter ? `${filter} && (${departmentFilter})` : `(${departmentFilter})`;
@ -160,11 +184,10 @@ export default function ReimbursementManagementPortal() {
submitter: userMap[record.submitted_by] submitter: userMap[record.submitted_by]
})); }));
setReimbursements(enrichedRecords);
// Load associated receipts // Load associated receipts
const receiptIds = enrichedRecords.flatMap(r => r.receipts || []); const receiptIds = enrichedRecords.flatMap(r => r.receipts || []);
let receiptMap: Record<string, ExtendedReceipt> = {};
if (receiptIds.length > 0) { if (receiptIds.length > 0) {
try { try {
const receiptRecords = await Promise.all( const receiptRecords = await Promise.all(
@ -200,7 +223,7 @@ export default function ReimbursementManagementPortal() {
const validReceipts = receiptRecords.filter((r): r is ExtendedReceipt => r !== null); const validReceipts = receiptRecords.filter((r): r is ExtendedReceipt => r !== null);
const receiptMap = Object.fromEntries( receiptMap = Object.fromEntries(
validReceipts.map(receipt => [receipt.id, receipt]) validReceipts.map(receipt => [receipt.id, receipt])
); );
setReceipts(receiptMap); setReceipts(receiptMap);
@ -217,6 +240,52 @@ export default function ReimbursementManagementPortal() {
// console.log('No receipt IDs found in reimbursements'); // console.log('No receipt IDs found in reimbursements');
setReceipts({}); setReceipts({});
} }
// Apply client-side search filtering
let filteredRecords = enrichedRecords;
if (isSearching) {
const searchTerm = filters.search.toLowerCase().trim();
filteredRecords = enrichedRecords.filter(record => {
// Search in title
if (record.title.toLowerCase().includes(searchTerm)) return true;
// Search in submitter name
if (record.submitter?.name?.toLowerCase().includes(searchTerm)) return true;
// Search in date (multiple formats)
const date = new Date(record.date_of_purchase);
const dateFormats = [
date.toLocaleDateString(), // Default locale format
date.toLocaleDateString('en-US'), // MM/DD/YYYY
date.toISOString().split('T')[0], // YYYY-MM-DD
date.toDateString(), // "Mon Jan 01 2024"
`${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`, // M/D/YYYY
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` // YYYY-MM-DD
];
if (dateFormats.some(format => format.toLowerCase().includes(searchTerm))) return true;
// Search in receipt location names
const reimbursementReceipts = record.receipts?.map(id => receiptMap[id]).filter(Boolean) || [];
if (reimbursementReceipts.some(receipt =>
receipt.location_name?.toLowerCase().includes(searchTerm) ||
receipt.location_address?.toLowerCase().includes(searchTerm)
)) return true;
// Search in department
if (record.department.toLowerCase().includes(searchTerm)) return true;
// Search in status
if (record.status.toLowerCase().replace('_', ' ').includes(searchTerm)) return true;
// Search in additional info
if (record.additional_info?.toLowerCase().includes(searchTerm)) return true;
return false;
});
}
setReimbursements(filteredRecords);
} catch (error) { } catch (error) {
console.error('Error loading reimbursements:', error); console.error('Error loading reimbursements:', error);
toast.error('Failed to load reimbursements. Please try again later.'); toast.error('Failed to load reimbursements. Please try again later.');
@ -366,7 +435,7 @@ export default function ReimbursementManagementPortal() {
}; };
// Update the updateStatus function // Update the updateStatus function
const updateStatus = async (id: string, status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'in_progress' | 'paid') => { const updateStatus = async (id: string, status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'in_progress' | 'paid', showToast: boolean = true) => {
try { try {
setLoadingStatus(true); setLoadingStatus(true);
const update = Update.getInstance(); const update = Update.getInstance();
@ -375,15 +444,28 @@ export default function ReimbursementManagementPortal() {
if (!userId) throw new Error('User not authenticated'); if (!userId) throw new Error('User not authenticated');
// Store previous status for email notification
const previousStatus = selectedReimbursement?.status || 'unknown';
await update.updateFields('reimbursement', id, { status }); await update.updateFields('reimbursement', id, { status });
// Add audit log for status change // Add audit log for status change
await addAuditLog(id, 'status_change', { await addAuditLog(id, 'status_change', {
from: selectedReimbursement?.status, from: previousStatus,
to: status to: status
}); });
// Send email notification
try {
await EmailClient.notifyStatusChange(id, status, previousStatus, userId);
} catch (emailError) {
console.error('Failed to send email notification:', emailError);
// Don't fail the entire operation if email fails
}
if (showToast) {
toast.success(`Reimbursement ${status} successfully`); toast.success(`Reimbursement ${status} successfully`);
}
await refreshAuditData(id); await refreshAuditData(id);
} catch (error) { } catch (error) {
console.error('Error updating status:', error); console.error('Error updating status:', error);
@ -483,8 +565,7 @@ export default function ReimbursementManagementPortal() {
} }
})); }));
setSelectedReceipt(receipt); // Don't show the receipt modal when auditing
setShowReceiptModal(true);
toast.success('Receipt audited successfully'); toast.success('Receipt audited successfully');
} catch (error) { } catch (error) {
console.error('Error auditing receipt:', error); console.error('Error auditing receipt:', error);
@ -582,6 +663,21 @@ export default function ReimbursementManagementPortal() {
is_private: isPrivateNote is_private: isPrivateNote
}); });
// Send email notification for public comments
if (!isPrivateNote) {
try {
await EmailClient.notifyComment(
selectedReimbursement.id,
auditNote.trim(),
userId,
isPrivateNote
);
} catch (emailError) {
console.error('Failed to send comment email notification:', emailError);
// Don't fail the entire operation if email fails
}
}
toast.success('Audit note saved successfully'); toast.success('Audit note saved successfully');
setAuditNote(''); setAuditNote('');
setIsPrivateNote(true); setIsPrivateNote(true);
@ -613,8 +709,8 @@ export default function ReimbursementManagementPortal() {
try { try {
setLoadingStatus(true); setLoadingStatus(true);
// First update the status // First update the status (passing false to suppress the toast message)
await updateStatus(rejectingId, 'rejected'); await updateStatus(rejectingId, 'rejected', false);
// Then add the rejection reason as a public note // Then add the rejection reason as a public note
const auth = Authentication.getInstance(); const auth = Authentication.getInstance();
@ -693,12 +789,59 @@ export default function ReimbursementManagementPortal() {
<h2 className="text-lg sm:text-xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"> <h2 className="text-lg sm:text-xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
Reimbursement Requests Reimbursement Requests
</h2> </h2>
<div className="flex flex-wrap items-center gap-2">
<span className="badge badge-primary badge-md font-medium"> <span className="badge badge-primary badge-md font-medium">
{reimbursements.length} Total {reimbursements.length} Total
</span> </span>
{filters.hidePaid && (
<span className="badge badge-ghost badge-sm font-medium" title="Paid reimbursements are automatically hidden">
<Icon icon="heroicons:eye-slash" className="h-3 w-3 mr-1" />
Paid Hidden
</span>
)}
{filters.hideRejected && (
<span className="badge badge-ghost badge-sm font-medium" title="Rejected reimbursements are automatically hidden">
<Icon icon="heroicons:eye-slash" className="h-3 w-3 mr-1" />
Rejected Hidden
</span>
)}
</div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
{/* Search Bar */}
<div className="form-control sm:col-span-2">
<div className="join h-9 relative">
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
<Icon icon="heroicons:magnifying-glass" className="h-4 w-4" />
</div>
<input
type="text"
className={`input input-bordered input-sm w-full focus:outline-none h-full join-item rounded-l-none ${filters.search ? 'pr-16' : 'pr-8'}`}
placeholder="Search by title, user, date, receipt location..."
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
/>
{filters.search && (
<button
className="btn btn-ghost btn-sm absolute right-2 top-0 h-full px-2"
onClick={() => setFilters(prev => ({ ...prev, search: '' }))}
>
<Icon icon="heroicons:x-mark" className="h-4 w-4" />
</button>
)}
</div>
{filters.search && (
<div className="label py-1">
<span className="label-text-alt text-info">
<Icon icon="heroicons:information-circle" className="h-3 w-3 inline mr-1" />
Search includes all reimbursements (including paid/rejected)
</span>
</div>
)}
</div>
{/* Status Filter */}
<div className="form-control"> <div className="form-control">
<div className="join h-9 relative"> <div className="join h-9 relative">
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item"> <div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
@ -754,6 +897,7 @@ export default function ReimbursementManagementPortal() {
</div> </div>
</div> </div>
{/* Department Filter */}
<div className="form-control"> <div className="form-control">
<div className="join h-9 relative"> <div className="join h-9 relative">
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item"> <div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
@ -806,6 +950,7 @@ export default function ReimbursementManagementPortal() {
</div> </div>
</div> </div>
{/* Date Range Filter */}
<div className="form-control"> <div className="form-control">
<div className="join h-9"> <div className="join h-9">
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item"> <div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
@ -824,7 +969,8 @@ export default function ReimbursementManagementPortal() {
</div> </div>
</div> </div>
<div className="form-control md:col-span-2"> {/* Sort Controls */}
<div className="form-control">
<div className="join h-9"> <div className="join h-9">
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item"> <div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
<Icon icon="heroicons:arrows-up-down" className="h-4 w-4" /> <Icon icon="heroicons:arrows-up-down" className="h-4 w-4" />
@ -850,6 +996,54 @@ export default function ReimbursementManagementPortal() {
</div> </div>
</div> </div>
</div> </div>
{/* Additional Filter Options */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-4 border-t border-base-300 mt-4">
<div className="form-control">
<label className="label cursor-pointer justify-start gap-3 p-0">
<input
type="checkbox"
className="checkbox checkbox-primary checkbox-sm"
checked={filters.hidePaid}
onChange={(e) => setFilters({ ...filters, hidePaid: e.target.checked })}
/>
<div className="flex items-center gap-2">
<Icon icon="heroicons:eye-slash" className="h-4 w-4 text-base-content/70" />
<span className="label-text font-medium">Auto-hide paid requests</span>
</div>
</label>
</div>
<div className="form-control">
<label className="label cursor-pointer justify-start gap-3 p-0">
<input
type="checkbox"
className="checkbox checkbox-primary checkbox-sm"
checked={filters.hideRejected}
onChange={(e) => setFilters({ ...filters, hideRejected: e.target.checked })}
/>
<div className="flex items-center gap-2">
<Icon icon="heroicons:eye-slash" className="h-4 w-4 text-base-content/70" />
<span className="label-text font-medium">Auto-hide rejected requests</span>
</div>
</label>
</div>
<div className="form-control">
<label className="label cursor-pointer justify-start gap-3 p-0">
<input
type="checkbox"
className="checkbox checkbox-primary checkbox-sm"
checked={filters.compactView}
onChange={(e) => setFilters({ ...filters, compactView: e.target.checked })}
/>
<div className="flex items-center gap-2">
<Icon icon="heroicons:list-bullet" className="h-4 w-4 text-base-content/70" />
<span className="label-text font-medium">Compact view</span>
</div>
</label>
</div>
</div>
</motion.div> </motion.div>
{loading ? ( {loading ? (
@ -873,7 +1067,7 @@ export default function ReimbursementManagementPortal() {
</motion.div> </motion.div>
) : ( ) : (
<AnimatePresence> <AnimatePresence>
<div className="space-y-4"> <div className={`${filters.compactView ? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2' : 'space-y-4'}`}>
{reimbursements.map((reimbursement, index) => ( {reimbursements.map((reimbursement, index) => (
<motion.div <motion.div
key={reimbursement.id} key={reimbursement.id}
@ -884,6 +1078,34 @@ export default function ReimbursementManagementPortal() {
${selectedReimbursement?.id === reimbursement.id ? 'ring-2 ring-primary shadow-lg scale-[1.02]' : 'hover:scale-[1.01] hover:shadow-md'}`} ${selectedReimbursement?.id === reimbursement.id ? 'ring-2 ring-primary shadow-lg scale-[1.02]' : 'hover:scale-[1.01] hover:shadow-md'}`}
onClick={() => setSelectedReimbursement(reimbursement)} onClick={() => setSelectedReimbursement(reimbursement)}
> >
{filters.compactView ? (
// Compact Grid View
<div className="card-body p-3">
<div className="space-y-2">
<h3 className="font-semibold text-sm group-hover:text-primary transition-colors line-clamp-2 leading-tight">
{reimbursement.title}
</h3>
<div className="flex items-center justify-between text-xs text-base-content/70">
<span>{new Date(reimbursement.date_of_purchase).toLocaleDateString()}</span>
<span className="font-mono font-bold text-primary text-sm">
${reimbursement.total_amount.toFixed(2)}
</span>
</div>
<div className="flex justify-center">
<span className={`badge badge-sm ${reimbursement.status === 'approved' ? 'badge-success' :
reimbursement.status === 'rejected' ? 'badge-error' :
reimbursement.status === 'under_review' ? 'badge-info' :
reimbursement.status === 'in_progress' ? 'badge-warning' :
reimbursement.status === 'paid' ? 'badge-success' :
'badge-ghost'
} capitalize font-medium whitespace-nowrap`}>
{reimbursement.status.replace('_', ' ')}
</span>
</div>
</div>
</div>
) : (
// Regular View
<div className="card-body p-5"> <div className="card-body p-5">
<div className="flex justify-between items-start gap-4"> <div className="flex justify-between items-start gap-4">
<div className="space-y-2 flex-1 min-w-0"> <div className="space-y-2 flex-1 min-w-0">
@ -925,6 +1147,7 @@ export default function ReimbursementManagementPortal() {
</div> </div>
</div> </div>
</div> </div>
)}
</motion.div> </motion.div>
))} ))}
</div> </div>

View file

@ -0,0 +1,50 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'md',
fullWidth = false,
className = '',
disabled = false,
...props
}) => {
// Base classes
const baseClasses = 'font-medium rounded-md focus:outline-none transition-colors';
// Size classes
const sizeClasses = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-2.5 text-base',
};
// Variant classes
const variantClasses = {
primary: `bg-blue-600 text-white hover:bg-blue-700 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`,
secondary: `bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`,
danger: `bg-red-600 text-white hover:bg-red-700 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`,
};
// Width classes
const widthClasses = fullWidth ? 'w-full' : '';
// Combine all classes
const buttonClasses = `${baseClasses} ${sizeClasses[size]} ${variantClasses[variant]} ${widthClasses} ${className}`;
return (
<button
className={buttonClasses}
disabled={disabled}
{...props}
>
{children}
</button>
);
};

View file

@ -14,6 +14,8 @@ interface ImageWithFallbackProps {
const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) => { const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) => {
const [imgSrc, setImgSrc] = useState<string>(url); const [imgSrc, setImgSrc] = useState<string>(url);
const [isObjectUrl, setIsObjectUrl] = useState<boolean>(false); const [isObjectUrl, setIsObjectUrl] = useState<boolean>(false);
const [errorCount, setErrorCount] = useState<number>(0);
const maxRetries = 2;
// Clean up object URL when component unmounts // Clean up object URL when component unmounts
useEffect(() => { useEffect(() => {
@ -24,13 +26,51 @@ const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) =
}; };
}, [imgSrc, url, isObjectUrl]); }, [imgSrc, url, isObjectUrl]);
// Reset when URL changes
useEffect(() => {
setImgSrc(url);
setIsObjectUrl(false);
setErrorCount(0);
}, [url]);
// Special handling for blob URLs
useEffect(() => {
const handleBlobUrl = async () => {
if (url.startsWith('blob:') && !isObjectUrl) {
try {
// For blob URLs, we don't need to fetch again, just set directly
setImgSrc(url);
} catch (error) {
console.error('Error with blob URL:', error);
}
}
};
handleBlobUrl();
}, [url, isObjectUrl]);
const handleError = async () => { const handleError = async () => {
console.error('Image failed to load:', url); // Prevent infinite retry loops
if (errorCount >= maxRetries) {
console.error(`Image failed to load after ${maxRetries} attempts:`, url);
onError('Failed to load image after multiple attempts. The file may be corrupted or unsupported.');
return;
}
setErrorCount(prev => prev + 1);
console.error(`Image failed to load (attempt ${errorCount + 1}):`, url);
try { try {
// Skip fetch for blob URLs that already failed
if (url.startsWith('blob:')) {
throw new Error('Blob URL failed to load directly');
}
// Try to fetch the image as a blob and create an object URL // Try to fetch the image as a blob and create an object URL
// console.log('Trying to fetch image as blob:', url); const response = await fetch(url, {
const response = await fetch(url, { mode: 'cors' }); mode: 'cors',
cache: 'no-cache' // Avoid caching issues
});
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
@ -38,27 +78,24 @@ const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) =
const blob = await response.blob(); const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
// console.log('Created object URL:', objectUrl);
// Update the image source with the object URL // Update the image source with the object URL
setImgSrc(objectUrl); setImgSrc(objectUrl);
setIsObjectUrl(true); setIsObjectUrl(true);
} catch (fetchError) { } catch (fetchError) {
console.error('Error fetching image as blob:', fetchError); console.error('Error fetching image as blob:', fetchError);
onError('Failed to load image. This might be due to permission issues or the file may not exist.');
// Log additional details // Only show error to user on final retry
// console.log('Image URL that failed:', url); if (errorCount >= maxRetries - 1) {
// console.log('Current auth status:', onError('Failed to load image. This might be due to permission issues or the file may not exist.');
// Authentication.getInstance().isAuthenticated() ? 'Authenticated' : 'Not authenticated' }
// );
} }
}; };
return ( return (
<img <img
src={imgSrc} src={imgSrc}
alt={filename} alt={filename || 'Image preview'}
className="max-w-full h-auto rounded-lg" className="max-w-full h-auto rounded-lg"
loading="lazy" loading="lazy"
onError={handleError} onError={handleError}
@ -167,6 +204,22 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
setState(prev => ({ ...prev, loading: true, error: null })); setState(prev => ({ ...prev, loading: true, error: null }));
try { try {
// Check cache first
const cacheKey = `${state.url}_${state.filename}`;
const cachedData = contentCache.get(cacheKey);
if (cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) {
// Use cached data
setState(prev => ({
...prev,
content: cachedData.content,
fileType: cachedData.fileType,
loading: false
}));
loadingRef.current = false;
return;
}
// Special handling for PDFs // Special handling for PDFs
if (state.url.endsWith('.pdf')) { if (state.url.endsWith('.pdf')) {
setState(prev => ({ setState(prev => ({
@ -175,12 +228,377 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
fileType: 'application/pdf', fileType: 'application/pdf',
loading: false loading: false
})); }));
// Cache the result
contentCache.set(cacheKey, {
content: 'pdf',
fileType: 'application/pdf',
timestamp: Date.now()
});
loadingRef.current = false; loadingRef.current = false;
return; return;
} }
// Rest of your existing loadContent logic // Handle image files
// ... existing content loading code ... const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'];
if (imageExtensions.some(ext => state.url.toLowerCase().endsWith(ext) ||
(state.filename && state.filename.toLowerCase().endsWith(ext)))) {
setState(prev => ({
...prev,
content: 'image',
fileType: 'image/' + (state.url.split('.').pop() || 'jpeg'),
loading: false
}));
// Cache the result
contentCache.set(cacheKey, {
content: 'image',
fileType: 'image/' + (state.url.split('.').pop() || 'jpeg'),
timestamp: Date.now()
});
loadingRef.current = false;
return;
}
// Handle video files
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi'];
if (videoExtensions.some(ext => state.url.toLowerCase().endsWith(ext) ||
(state.filename && state.filename.toLowerCase().endsWith(ext)))) {
setState(prev => ({
...prev,
content: 'video',
fileType: 'video/' + (state.url.split('.').pop() || 'mp4'),
loading: false
}));
// Cache the result
contentCache.set(cacheKey, {
content: 'video',
fileType: 'video/' + (state.url.split('.').pop() || 'mp4'),
timestamp: Date.now()
});
loadingRef.current = false;
return;
}
// For other file types, try to fetch the content
// Handle blob URLs (for local file previews)
if (state.url.startsWith('blob:')) {
try {
// Determine file type from filename if available
let fileType = '';
if (state.filename) {
const extension = state.filename.split('.').pop()?.toLowerCase();
if (extension) {
switch (extension) {
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'webp':
case 'bmp':
case 'svg':
fileType = `image/${extension === 'jpg' ? 'jpeg' : extension}`;
break;
case 'mp4':
case 'webm':
case 'ogg':
case 'mov':
fileType = `video/${extension}`;
break;
case 'pdf':
fileType = 'application/pdf';
break;
case 'doc':
fileType = 'application/msword';
break;
case 'docx':
fileType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
break;
case 'xls':
fileType = 'application/vnd.ms-excel';
break;
case 'xlsx':
fileType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
break;
case 'ppt':
fileType = 'application/vnd.ms-powerpoint';
break;
case 'pptx':
fileType = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
break;
case 'txt':
case 'md':
case 'js':
case 'jsx':
case 'ts':
case 'tsx':
case 'html':
case 'css':
case 'json':
case 'yml':
case 'yaml':
case 'csv':
fileType = 'text/plain';
break;
default:
fileType = 'application/octet-stream';
}
}
}
// Try to fetch the blob
const response = await fetch(state.url);
if (!response.ok) {
throw new Error(`Failed to fetch blob: ${response.status}`);
}
const blob = await response.blob();
// If we couldn't determine file type from filename, use the blob type
if (!fileType && blob.type) {
fileType = blob.type;
}
// Handle different file types
if (fileType.startsWith('image/') ||
(state.filename && /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(state.filename))) {
setState(prev => ({
...prev,
content: 'image',
fileType: fileType || 'image/jpeg',
loading: false
}));
// Cache the result
contentCache.set(cacheKey, {
content: 'image',
fileType: fileType || 'image/jpeg',
timestamp: Date.now()
});
} else if (fileType.startsWith('video/') ||
(state.filename && /\.(mp4|webm|ogg|mov|avi)$/i.test(state.filename))) {
setState(prev => ({
...prev,
content: 'video',
fileType: fileType || 'video/mp4',
loading: false
}));
// Cache the result
contentCache.set(cacheKey, {
content: 'video',
fileType: fileType || 'video/mp4',
timestamp: Date.now()
});
} else if (fileType === 'application/pdf' ||
(state.filename && /\.pdf$/i.test(state.filename))) {
setState(prev => ({
...prev,
content: 'pdf',
fileType: 'application/pdf',
loading: false
}));
// Cache the result
contentCache.set(cacheKey, {
content: 'pdf',
fileType: 'application/pdf',
timestamp: Date.now()
});
} else if (
fileType === 'application/msword' ||
fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
fileType === 'application/vnd.ms-excel' ||
fileType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
fileType === 'application/vnd.ms-powerpoint' ||
fileType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
(state.filename && /\.(doc|docx|xls|xlsx|ppt|pptx)$/i.test(state.filename))
) {
// Handle Office documents with a document icon and download option
const extension = state.filename?.split('.').pop()?.toLowerCase() || '';
let documentType = 'document';
if (['xls', 'xlsx'].includes(extension)) {
documentType = 'spreadsheet';
} else if (['ppt', 'pptx'].includes(extension)) {
documentType = 'presentation';
}
setState(prev => ({
...prev,
content: `document-${documentType}`,
fileType: fileType || `application/${documentType}`,
loading: false
}));
// Cache the result
contentCache.set(cacheKey, {
content: `document-${documentType}`,
fileType: fileType || `application/${documentType}`,
timestamp: Date.now()
});
} else {
// For text files, read the content
try {
const text = await blob.text();
setState(prev => ({
...prev,
content: text,
fileType: fileType || 'text/plain',
loading: false
}));
// Cache the result
contentCache.set(cacheKey, {
content: text,
fileType: fileType || 'text/plain',
timestamp: Date.now()
});
} catch (textError) {
console.error('Error reading blob as text:', textError);
throw new Error('Failed to read file content');
}
}
loadingRef.current = false;
return;
} catch (error) {
console.error('Error processing blob URL:', error);
setState(prev => ({
...prev,
error: 'Failed to load file preview. Please try again or proceed with upload.',
loading: false
}));
loadingRef.current = false;
return;
}
}
// For remote files
const response = await fetch(state.url);
if (!response.ok) {
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('content-type') || '';
if (contentType.startsWith('image/')) {
setState(prev => ({
...prev,
content: 'image',
fileType: contentType,
loading: false
}));
// Cache the result
contentCache.set(cacheKey, {
content: 'image',
fileType: contentType,
timestamp: Date.now()
});
} else if (contentType.startsWith('video/')) {
setState(prev => ({
...prev,
content: 'video',
fileType: contentType,
loading: false
}));
// Cache the result
contentCache.set(cacheKey, {
content: 'video',
fileType: contentType,
timestamp: Date.now()
});
} else if (contentType === 'application/pdf') {
setState(prev => ({
...prev,
content: 'pdf',
fileType: contentType,
loading: false
}));
// Cache the result
contentCache.set(cacheKey, {
content: 'pdf',
fileType: contentType,
timestamp: Date.now()
});
} else if (
contentType === 'application/msword' ||
contentType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
(state.filename && /\.(doc|docx)$/i.test(state.filename))
) {
setState(prev => ({
...prev,
content: 'document-document',
fileType: contentType || 'application/document',
loading: false
}));
// Cache the result
contentCache.set(cacheKey, {
content: 'document-document',
fileType: contentType || 'application/document',
timestamp: Date.now()
});
} else if (
contentType === 'application/vnd.ms-excel' ||
contentType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
(state.filename && /\.(xls|xlsx)$/i.test(state.filename))
) {
setState(prev => ({
...prev,
content: 'document-spreadsheet',
fileType: contentType || 'application/spreadsheet',
loading: false
}));
// Cache the result
contentCache.set(cacheKey, {
content: 'document-spreadsheet',
fileType: contentType || 'application/spreadsheet',
timestamp: Date.now()
});
} else if (
contentType === 'application/vnd.ms-powerpoint' ||
contentType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
(state.filename && /\.(ppt|pptx)$/i.test(state.filename))
) {
setState(prev => ({
...prev,
content: 'document-presentation',
fileType: contentType || 'application/presentation',
loading: false
}));
// Cache the result
contentCache.set(cacheKey, {
content: 'document-presentation',
fileType: contentType || 'application/presentation',
timestamp: Date.now()
});
} else {
// For text files, read the content
const text = await response.text();
setState(prev => ({
...prev,
content: text,
fileType: contentType,
loading: false
}));
// Cache the result
contentCache.set(cacheKey, {
content: text,
fileType: contentType,
timestamp: Date.now()
});
}
} catch (err) { } catch (err) {
console.error('Error loading content:', err); console.error('Error loading content:', err);
setState(prev => ({ setState(prev => ({
@ -193,8 +611,20 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
}, [state.url]); }, [state.url]);
useEffect(() => { useEffect(() => {
if (!state.url || (!state.isVisible && isModal)) return; if (!state.url) return;
// For modal, only load when visible
if (isModal && !state.isVisible) return;
// Reset loading state when URL changes
loadingRef.current = false;
// Small timeout to ensure state updates are processed
const timer = setTimeout(() => {
loadContent(); loadContent();
}, 50);
return () => clearTimeout(timer);
}, [state.url, state.isVisible, isModal, loadContent]); }, [state.url, state.isVisible, isModal, loadContent]);
// Intersection observer effect // Intersection observer effect
@ -364,7 +794,14 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
// Update the Try Again button handler // Update the Try Again button handler
const handleTryAgain = useCallback(() => { const handleTryAgain = useCallback(() => {
loadingRef.current = false; // Reset loading ref loadingRef.current = false; // Reset loading ref
setState(prev => ({
...prev,
error: null,
loading: true
}));
setTimeout(() => {
loadContent(); loadContent();
}, 100); // Small delay to ensure state is updated
}, [loadContent]); }, [loadContent]);
// If URL is empty, show a message // If URL is empty, show a message
@ -399,7 +836,8 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
</div> </div>
<div className="preview-content overflow-auto border border-base-300 rounded-lg bg-base-200/50 relative"> <div className="preview-content overflow-auto border border-base-300 rounded-lg bg-base-200/50 relative">
{!state.loading && !state.error && state.content === null && ( {/* Initial loading state - show immediately when URL is provided but content hasn't loaded yet */}
{state.url && !state.loading && !state.error && state.content === null && (
<div className="flex justify-center items-center p-8"> <div className="flex justify-center items-center p-8">
<span className="loading loading-spinner loading-lg"></span> <span className="loading loading-spinner loading-lg"></span>
</div> </div>
@ -448,22 +886,39 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
{!state.loading && !state.error && state.content === 'video' && ( {!state.loading && !state.error && state.content === 'video' && (
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg"> <div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
<div className="w-full max-w-2xl">
<video <video
controls controls
className="max-w-full h-auto rounded-lg" className="max-w-full h-auto rounded-lg"
preload="metadata" preload="metadata"
src={state.url}
onError={(e) => { onError={(e) => {
console.error('Video failed to load:', e); console.error('Video failed to load:', e);
// For blob URLs, try a different approach
if (state.url.startsWith('blob:')) {
const videoElement = e.target as HTMLVideoElement;
// Try to set the src directly
try {
videoElement.src = state.url;
videoElement.load();
return;
} catch (directError) {
console.error('Direct src assignment failed:', directError);
}
}
setState(prev => ({ setState(prev => ({
...prev, ...prev,
error: 'Failed to load video. This might be due to permission issues or the file may not exist.' error: 'Failed to load video. This might be due to permission issues or the file may not exist.'
})); }));
}} }}
> >
<source src={state.url} type={state.fileType || 'video/mp4'} />
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
</div> </div>
</div>
)} )}
{!state.loading && !state.error && state.content === 'pdf' && ( {!state.loading && !state.error && state.content === 'pdf' && (
@ -522,6 +977,41 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
</div> </div>
)} )}
{!state.loading && !state.error && state.content && state.content.startsWith('document-') && (
<div className="flex flex-col items-center justify-center bg-base-200 p-8 rounded-b-lg">
<div className="bg-primary/10 p-6 rounded-full mb-6">
<Icon
icon={
state.content === 'document-spreadsheet'
? "mdi:file-excel"
: state.content === 'document-presentation'
? "mdi:file-powerpoint"
: "mdi:file-word"
}
className="h-16 w-16 text-primary"
/>
</div>
<h3 className="text-xl font-semibold mb-2">{state.filename}</h3>
<p className="text-base-content/70 mb-6 text-center max-w-md">
This document cannot be previewed in the browser. Please download it to view its contents.
</p>
<a
href={state.url}
download={state.filename}
className="btn btn-primary btn-lg gap-2"
>
<Icon icon="mdi:download" className="h-5 w-5" />
Download {
state.content === 'document-spreadsheet'
? 'Spreadsheet'
: state.content === 'document-presentation'
? 'Presentation'
: 'Document'
}
</a>
</div>
)}
{!state.loading && !state.error && state.content && !['image', 'video', 'pdf'].includes(state.content) && ( {!state.loading && !state.error && state.content && !['image', 'video', 'pdf'].includes(state.content) && (
<div className="overflow-x-auto max-h-[600px] bg-base-200"> <div className="overflow-x-auto max-h-[600px] bg-base-200">
<div className={`p-1 ${state.filename.toLowerCase().endsWith('.csv') ? 'p-4' : ''}`}> <div className={`p-1 ${state.filename.toLowerCase().endsWith('.csv') ? 'p-4' : ''}`}>

View file

@ -1,6 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Authentication } from "../../../scripts/pocketbase/Authentication"; import { Authentication } from "../../../scripts/pocketbase/Authentication";
import type { User } from "../../../schemas/pocketbase/schema";
import FirstTimeLoginPopup from "./FirstTimeLoginPopup"; import FirstTimeLoginPopup from "./FirstTimeLoginPopup";
interface FirstTimeLoginManagerProps { interface FirstTimeLoginManagerProps {

View file

@ -84,20 +84,15 @@ export default function ThemeToggle() {
}; };
return ( return (
<div className="relative"> <div className="dropdown dropdown-end">
<button <button
onClick={handleToggle} onClick={handleToggle}
className={`inline-flex items-center justify-center rounded-full w-8 h-8 text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground ${isLoading ? 'opacity-70' : ''}`} className={`btn btn-circle btn-sm ${isLoading ? 'loading' : ''}`}
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`} aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme (Light mode is experimental)`} title={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme (Light mode is experimental)`}
disabled={isLoading} disabled={isLoading}
> >
{isLoading ? ( {!isLoading && (
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
theme === 'light' ? ( theme === 'light' ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
@ -109,9 +104,9 @@ export default function ThemeToggle() {
) )
)} )}
</button> </button>
<div className="absolute right-0 z-10 mt-2 w-52 origin-top-right rounded-md bg-card shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none hidden group-hover:block"> <div className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52 mt-2 text-xs">
<div className="p-3 text-xs"> <div className="p-2">
<p className="font-bold text-amber-600 dark:text-amber-400 mb-1">Warning:</p> <p className="font-bold text-warning mb-1">Warning:</p>
<p>Light mode is experimental and not fully supported yet. Some UI elements may not display correctly.</p> <p>Light mode is experimental and not fully supported yet. Some UI elements may not display correctly.</p>
</div> </div>
</div> </div>

View file

@ -0,0 +1,85 @@
import React, { useEffect, useState } from 'react';
interface ToastProps {
message: string;
type?: 'success' | 'error' | 'info' | 'warning';
duration?: number;
onClose?: () => void;
}
export const Toast: React.FC<ToastProps> = ({
message,
type = 'info',
duration = 3000,
onClose,
}) => {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
if (onClose) onClose();
}, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
if (!isVisible) return null;
// Type-based styling
const typeStyles = {
success: 'bg-green-100 border-green-500 text-green-700 dark:bg-green-800 dark:text-green-100',
error: 'bg-red-100 border-red-500 text-red-700 dark:bg-red-800 dark:text-red-100',
warning: 'bg-yellow-100 border-yellow-500 text-yellow-700 dark:bg-yellow-800 dark:text-yellow-100',
info: 'bg-blue-100 border-blue-500 text-blue-700 dark:bg-blue-800 dark:text-blue-100',
};
// Icons based on type
const icons = {
success: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd"></path>
</svg>
),
error: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd"></path>
</svg>
),
warning: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd"></path>
</svg>
),
info: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zm-1 7a1 1 0 100 2h.01a1 1 0 100-2H10z" clipRule="evenodd"></path>
</svg>
),
};
return (
<div className="fixed top-4 right-4 z-50 animate-fade-in">
<div className={`flex items-center p-4 mb-4 border-l-4 rounded-md shadow-md ${typeStyles[type]}`} role="alert">
<div className="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 mr-3">
{icons[type]}
</div>
<div className="ml-3 text-sm font-medium">{message}</div>
<button
type="button"
className="ml-auto -mx-1.5 -my-1.5 rounded-lg p-1.5 inline-flex h-8 w-8 hover:bg-gray-200 dark:hover:bg-gray-700"
onClick={() => {
setIsVisible(false);
if (onClose) onClose();
}}
aria-label="Close"
>
<span className="sr-only">Close</span>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd"></path>
</svg>
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,156 @@
import React, { useState, useRef, useCallback } from 'react';
import { Icon } from '@iconify/react';
import { motion } from 'framer-motion';
import FilePreview from './FilePreview';
interface ZoomablePreviewProps {
url: string;
filename: string;
}
export default function ZoomablePreview({ url, filename }: ZoomablePreviewProps) {
const [zoom, setZoom] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const zoomLevels = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3];
const currentZoomIndex = zoomLevels.findIndex(level => Math.abs(level - zoom) < 0.01);
const handleZoomIn = useCallback(() => {
const nextIndex = Math.min(currentZoomIndex + 1, zoomLevels.length - 1);
setZoom(zoomLevels[nextIndex]);
}, [currentZoomIndex]);
const handleZoomOut = useCallback(() => {
const prevIndex = Math.max(currentZoomIndex - 1, 0);
setZoom(zoomLevels[prevIndex]);
}, [currentZoomIndex]);
const handleZoomReset = useCallback(() => {
setZoom(1);
setPosition({ x: 0, y: 0 });
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (zoom > 1) {
setIsDragging(true);
setDragStart({
x: e.clientX - position.x,
y: e.clientY - position.y
});
}
}, [zoom, position]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (isDragging && zoom > 1) {
setPosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y
});
}
}, [isDragging, dragStart, zoom]);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -1 : 1;
const newZoomIndex = Math.max(0, Math.min(zoomLevels.length - 1, currentZoomIndex + delta));
setZoom(zoomLevels[newZoomIndex]);
// Reset position when zooming out to 100% or less
if (zoomLevels[newZoomIndex] <= 1) {
setPosition({ x: 0, y: 0 });
}
}, [currentZoomIndex]);
return (
<div className="relative h-full">
{/* Zoom Controls */}
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2 bg-base-100/90 backdrop-blur-sm rounded-lg p-2 shadow-lg">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="btn btn-xs btn-ghost"
onClick={handleZoomIn}
disabled={currentZoomIndex >= zoomLevels.length - 1}
title="Zoom In"
>
<Icon icon="heroicons:plus" className="h-3 w-3" />
</motion.button>
<div className="text-xs text-center font-mono px-1">
{Math.round(zoom * 100)}%
</div>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="btn btn-xs btn-ghost"
onClick={handleZoomOut}
disabled={currentZoomIndex <= 0}
title="Zoom Out"
>
<Icon icon="heroicons:minus" className="h-3 w-3" />
</motion.button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="btn btn-xs btn-ghost"
onClick={handleZoomReset}
disabled={zoom === 1 && position.x === 0 && position.y === 0}
title="Reset Zoom"
>
<Icon icon="heroicons:arrows-pointing-out" className="h-3 w-3" />
</motion.button>
</div>
{/* Zoom Indicator */}
{zoom !== 1 && (
<div className="absolute top-4 left-4 z-10 bg-primary/90 backdrop-blur-sm text-primary-content text-xs px-2 py-1 rounded">
{zoom > 1 ? 'Click and drag to pan' : ''}
</div>
)}
{/* Preview Container */}
<div
ref={containerRef}
className="relative h-full overflow-hidden rounded-lg cursor-move"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
style={{
cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
}}
>
<div
className="h-full transition-transform duration-100"
style={{
transform: `scale(${zoom}) translate(${position.x / zoom}px, ${position.y / zoom}px)`,
transformOrigin: 'center center'
}}
>
<div className="p-4 h-full">
<FilePreview
url={url}
filename={filename}
isModal={false}
/>
</div>
</div>
</div>
{/* Usage Hint */}
<div className="absolute bottom-4 left-4 z-10 text-xs text-base-content/50 bg-base-100/80 backdrop-blur-sm px-2 py-1 rounded">
Scroll to zoom Click and drag to pan
</div>
</div>
);
}

View file

@ -17,7 +17,7 @@ import { LiaDotCircle } from "react-icons/lia";
To stay up to date, join discord server To stay up to date, join discord server
</p> </p>
</div> </div>
<Link href="https://www.facebook.com/ieeeucsd" target="_blank" className="mr-[20%] flex flex-col items-center"> <Link href="https://discord.gg/ubr2suwc2f" target="_blank" className="mr-[20%] flex flex-col items-center">
<div class="border-[0.15vw] rounded-full shadow-glow hover:scale-110 duration-300 p-[1vw] text-[2.2vw] text-ieee-black/80 border-ieee-blue-100 bg-gradient-radial from-white via-white to-white/40"> <div class="border-[0.15vw] rounded-full shadow-glow hover:scale-110 duration-300 p-[1vw] text-[2.2vw] text-ieee-black/80 border-ieee-blue-100 bg-gradient-radial from-white via-white to-white/40">
<FaDiscord /> <FaDiscord />
</div> </div>

View file

@ -36,6 +36,13 @@ sections:
component: "Officer_EventManagement" component: "Officer_EventManagement"
class: "text-info hover:text-info-focus" class: "text-info hover:text-info-focus"
officerEmailManagement:
title: "IEEE Email Management"
icon: "heroicons:envelope"
role: "general"
component: "Officer_EmailManagement"
class: "text-info hover:text-info-focus"
reimbursementManagement: reimbursementManagement:
title: "Reimbursement Management" title: "Reimbursement Management"
icon: "heroicons:credit-card" icon: "heroicons:credit-card"
@ -50,6 +57,13 @@ sections:
component: "Officer_EventRequestManagement" component: "Officer_EventRequestManagement"
class: "text-info hover:text-info-focus" class: "text-info hover:text-info-focus"
officerManagement:
title: "Officer Management"
icon: "heroicons:user-group"
role: "executive"
component: "OfficerManagement"
class: "text-info hover:text-info-focus"
eventRequestForm: eventRequestForm:
title: "Event Request Form" title: "Event Request Form"
icon: "heroicons:document-text" icon: "heroicons:document-text"
@ -58,19 +72,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:
@ -103,12 +117,13 @@ categories:
officer: officer:
title: "Officer Menu" title: "Officer Menu"
sections: ["eventManagement", "eventRequestForm"] sections: ["eventManagement", "officerEmailManagement", "eventRequestForm"]
role: "general" role: "general"
executive: executive:
title: "Executive Menu" title: "Executive Menu"
sections: ["reimbursementManagement", "eventRequestManagement"] sections:
["reimbursementManagement", "eventRequestManagement", "officerManagement"]
role: "executive" role: "executive"
admin: admin:
@ -118,10 +133,10 @@ categories:
sponsor: sponsor:
title: "Sponsor Portal" title: "Sponsor Portal"
sections: ["sponsorDashboard", "sponsorAnalytics"] sections: ["sponsorAnalytics", "resumeDatabase"]
role: "sponsor" role: "sponsor"
account: account:
title: "Account" title: "Account"
sections: ["settings", "logout"] sections: ["settings"]
role: "none" role: "none"

View file

@ -2,7 +2,7 @@
{ {
"title": "Quarterly Project", "title": "Quarterly Project",
"text": "QP is a quarter-long project competition. Students collaborate in teams of up to 6 to build a product aligned with a quarterly theme.", "text": "QP is a quarter-long project competition. Students collaborate in teams of up to 6 to build a product aligned with a quarterly theme.",
"link": "/quarterly", "link": "/projects/quarterly",
"number": "01", "number": "01",
"delay": "100" "delay": "100"
}, },

View file

@ -2,11 +2,10 @@
import Navbar from "../components/core/Navbar.astro"; import Navbar from "../components/core/Navbar.astro";
import Footer from "../components/core/Footer.astro"; import Footer from "../components/core/Footer.astro";
import InView from "../components/core/InView.astro"; import InView from "../components/core/InView.astro";
import { initTheme } from "../scripts/database/initTheme";
--- ---
<!doctype html> <!doctype html>
<html lang="en" class="w-full h-full m-0 bg-ieee-black"> <html lang="en" data-theme="dark" class="w-full h-full m-0 bg-ieee-black">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
@ -17,19 +16,19 @@ import { initTheme } from "../scripts/database/initTheme";
src="https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js" src="https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js"
></script> ></script>
<script is:inline> <script is:inline>
// Set a default theme until IndexedDB loads // Set default theme to dark if not already set
if (!localStorage.getItem("theme")) {
localStorage.setItem("theme", "dark");
document.documentElement.setAttribute("data-theme", "dark"); document.documentElement.setAttribute("data-theme", "dark");
} else {
// Apply saved theme
const savedTheme = localStorage.getItem("theme");
document.documentElement.setAttribute("data-theme", savedTheme);
}
</script> </script>
</head> </head>
<InView /> <InView />
<body class="w-full h-full m-0 bg-ieee-black"> <body class="w-full h-full m-0 bg-ieee-black">
<script>
// Initialize theme from IndexedDB
import { initTheme } from "../scripts/database/initTheme";
initTheme().catch((err) =>
console.error("Error initializing theme:", err)
);
</script>
<div class="text-white min-h-screen"> <div class="text-white min-h-screen">
<header class="sticky top-0 w-full z-[999]"> <header class="sticky top-0 w-full z-[999]">
<Navbar /> <Navbar />

View file

@ -292,7 +292,7 @@ async function sendCredentialsEmail(
Please change your password after your first login. Please change your password after your first login.
If you have any questions, please contact webmaster@ieeeucsd.org. If you have any questions, please contact webmaster@ieeeatucsd.org.
Best regards, Best regards,
IEEE UCSD Web Team IEEE UCSD Web Team
@ -311,7 +311,7 @@ async function sendWebmasterNotification(
) { ) {
// In a real implementation, you would use an email service // In a real implementation, you would use an email service
console.log(` console.log(`
To: webmaster@ieeeucsd.org To: webmaster@ieeeatucsd.org
Subject: New IEEE Email Account Created Subject: New IEEE Email Account Created
A new IEEE email account has been created: A new IEEE email account has been created:

View file

@ -0,0 +1,120 @@
import type { APIRoute } from 'astro';
import { initializeEmailServices, authenticatePocketBase } from '../../../scripts/email/EmailHelpers';
import {
sendEventRequestSubmissionEmail,
sendEventRequestStatusChangeEmail,
sendPRCompletedEmail,
sendDesignPRNotificationEmail
} from '../../../scripts/email/EventRequestEmailFunctions';
export const POST: APIRoute = async ({ request }) => {
try {
console.log('📨 Event request email API called');
const {
type,
eventRequestId,
previousStatus,
newStatus,
changedByUserId,
declinedReason,
additionalContext,
authData
} = await request.json();
console.log('📋 Request data:', {
type,
eventRequestId,
hasAuthData: !!authData,
authDataHasToken: !!(authData?.token),
authDataHasModel: !!(authData?.model),
newStatus,
previousStatus
});
if (!type || !eventRequestId) {
console.error('❌ Missing required parameters');
return new Response(
JSON.stringify({ error: 'Missing required parameters: type and eventRequestId' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Initialize services
const { pb, resend, fromEmail, replyToEmail } = await initializeEmailServices();
// Authenticate with PocketBase if auth data is provided
authenticatePocketBase(pb, authData);
let success = false;
console.log(`🎯 Processing event request email type: ${type}`);
switch (type) {
case 'event_request_submission':
success = await sendEventRequestSubmissionEmail(pb, resend, fromEmail, replyToEmail, {
eventRequestId
});
break;
case 'event_request_status_change':
if (!newStatus) {
return new Response(
JSON.stringify({ error: 'Missing newStatus for event request status change notification' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
success = await sendEventRequestStatusChangeEmail(pb, resend, fromEmail, replyToEmail, {
eventRequestId,
newStatus,
previousStatus,
changedByUserId,
declinedReason: declinedReason || additionalContext?.declinedReason
});
break;
case 'pr_completed':
success = await sendPRCompletedEmail(pb, resend, fromEmail, replyToEmail, {
eventRequestId
});
break;
case 'design_pr_notification':
success = await sendDesignPRNotificationEmail(pb, resend, fromEmail, replyToEmail, {
eventRequestId,
action: additionalContext?.action || 'unknown'
});
break;
default:
console.error('❌ Unknown event request notification type:', type);
return new Response(
JSON.stringify({ error: `Unknown event request notification type: ${type}` }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
console.log(`📊 Event request email operation result: ${success ? 'SUCCESS' : 'FAILED'}`);
return new Response(
JSON.stringify({
success,
message: success ? 'Event request email notification sent successfully' : 'Failed to send event request email notification'
}),
{
status: success ? 200 : 500,
headers: { 'Content-Type': 'application/json' }
}
);
} catch (error) {
console.error('❌ Error in event request email notification API:', error);
return new Response(
JSON.stringify({
error: 'Internal server error',
details: error instanceof Error ? error.message : 'Unknown error'
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View file

@ -0,0 +1,155 @@
import type { APIRoute } from 'astro';
import { OfficerEmailNotifications } from '../../../scripts/email/OfficerEmailNotifications';
import type { OfficerRoleChangeEmailData } from '../../../scripts/email/OfficerEmailNotifications';
import { initializeEmailServices, authenticatePocketBase } from '../../../scripts/email/EmailHelpers';
import { Collections } from '../../../schemas/pocketbase';
import type { User, Officer } from '../../../schemas/pocketbase/schema';
export const POST: APIRoute = async ({ request }) => {
try {
console.log('📨 Officer notification email API called');
const requestData = await request.json();
const {
type,
officerId,
additionalContext,
authData
} = requestData;
console.log('📋 Request data:', {
type,
officerId,
hasAdditionalContext: !!additionalContext,
hasAuthData: !!authData
});
if (type !== 'officer_role_change') {
console.error('❌ Invalid notification type for officer endpoint:', type);
return new Response(
JSON.stringify({ error: `Invalid notification type: ${type}` }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
if (!officerId) {
console.error('❌ Missing required parameter: officerId');
return new Response(
JSON.stringify({ error: 'Missing required parameter: officerId' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Initialize services - this creates a fresh PocketBase instance for server-side use
const { pb } = await initializeEmailServices();
// Authenticate with PocketBase if auth data is provided
authenticatePocketBase(pb, authData);
const emailService = OfficerEmailNotifications.getInstance();
// Get the officer record with user data
console.log('🔍 Fetching officer data...');
const officer = await pb.collection(Collections.OFFICERS).getOne(officerId, {
expand: 'user'
}) as Officer & { expand?: { user: User } };
if (!officer) {
console.error('❌ Officer not found:', officerId);
return new Response(
JSON.stringify({ error: 'Officer not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
// Get the user data from the expanded relation
const user = officer.expand?.user;
if (!user) {
console.error('❌ User data not found for officer:', officerId);
return new Response(
JSON.stringify({ error: 'User data not found for officer' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
// Extract additional context data
const {
previousRole,
previousType,
newRole,
newType,
changedByUserId,
isNewOfficer
} = additionalContext || {};
// Get the name of the person who made the change
let changedByName = '';
if (changedByUserId) {
try {
const changedByUser = await pb.collection(Collections.USERS).getOne(changedByUserId) as User;
changedByName = changedByUser?.name || 'Unknown User';
} catch (error) {
console.warn('Could not fetch changed by user name:', error);
changedByName = 'Unknown User';
}
}
// Prepare email data
const emailData: OfficerRoleChangeEmailData = {
user,
officer,
previousRole,
previousType,
newRole: newRole || officer.role,
newType: newType || officer.type,
changedBy: changedByName,
isNewOfficer: isNewOfficer || false
};
console.log('📧 Sending officer role change notification...');
console.log('📧 Email data:', {
userName: user.name,
userEmail: user.email,
officerRole: emailData.newRole,
officerType: emailData.newType,
previousRole: emailData.previousRole,
previousType: emailData.previousType,
changedBy: emailData.changedBy,
isNewOfficer: emailData.isNewOfficer
});
const success = await emailService.sendRoleChangeNotification(emailData);
if (success) {
console.log('✅ Officer role change notification sent successfully');
return new Response(
JSON.stringify({
success: true,
message: 'Officer role change notification sent successfully'
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} else {
console.error('❌ Failed to send officer role change notification');
return new Response(
JSON.stringify({
success: false,
error: 'Failed to send officer role change notification'
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
} catch (error) {
console.error('❌ Error in officer notification API:', error);
return new Response(
JSON.stringify({
success: false,
error: 'Internal server error',
details: error instanceof Error ? error.message : 'Unknown error'
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View file

@ -0,0 +1,910 @@
import type { APIRoute } from 'astro';
import { initializeEmailServices, authenticatePocketBase, getStatusColor, getStatusText, getNextStepsText } from '../../../scripts/email/EmailHelpers';
// Add function to generate status image URL (now SVG-based)
function getStatusImageUrl(status: string, baseUrl: string = ''): string {
return `${baseUrl}/api/generate-status-image?status=${status}&width=500&height=150`;
}
export const POST: APIRoute = async ({ request }) => {
try {
console.log('📨 Reimbursement email API called');
const {
type,
reimbursementId,
previousStatus,
newStatus,
changedByUserId,
comment,
commentByUserId,
isPrivate,
additionalContext,
authData,
useImageProgress = true // New option to use image instead of HTML progress (default: true for better email compatibility)
} = await request.json();
console.log('📋 Request data:', {
type,
reimbursementId,
hasAuthData: !!authData,
authDataHasToken: !!(authData?.token),
authDataHasModel: !!(authData?.model),
commentLength: comment?.length || 0,
commentByUserId,
isPrivate
});
if (!type || !reimbursementId) {
console.error('❌ Missing required parameters');
return new Response(
JSON.stringify({ error: 'Missing required parameters: type and reimbursementId' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Initialize services
const { pb, resend, fromEmail, replyToEmail } = await initializeEmailServices();
// Authenticate with PocketBase if auth data is provided (skip for test emails)
if (type !== 'test') {
authenticatePocketBase(pb, authData);
}
let success = false;
console.log(`🎯 Processing reimbursement email type: ${type}`);
switch (type) {
case 'status_change':
if (!newStatus) {
return new Response(
JSON.stringify({ error: 'Missing newStatus for status_change notification' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
success = await sendStatusChangeEmail(pb, resend, fromEmail, replyToEmail, {
reimbursementId,
newStatus,
previousStatus,
changedByUserId,
additionalContext,
useImageProgress
});
break;
case 'comment':
if (!comment || !commentByUserId) {
console.error('❌ Missing comment or commentByUserId for comment notification');
return new Response(
JSON.stringify({ error: 'Missing comment or commentByUserId for comment notification' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
success = await sendCommentEmail(pb, resend, fromEmail, replyToEmail, {
reimbursementId,
comment,
commentByUserId,
isPrivate: isPrivate || false
});
break;
case 'submission':
success = await sendSubmissionEmail(pb, resend, fromEmail, replyToEmail, {
reimbursementId
});
break;
case 'test':
const { email } = additionalContext || {};
if (!email) {
return new Response(
JSON.stringify({ error: 'Missing email for test notification' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
success = await sendTestEmail(resend, fromEmail, replyToEmail, email);
break;
default:
console.error('❌ Unknown reimbursement notification type:', type);
return new Response(
JSON.stringify({ error: `Unknown reimbursement notification type: ${type}` }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
console.log(`📊 Reimbursement email operation result: ${success ? 'SUCCESS' : 'FAILED'}`);
return new Response(
JSON.stringify({
success,
message: success ? 'Reimbursement email notification sent successfully' : 'Failed to send reimbursement email notification'
}),
{
status: success ? 200 : 500,
headers: { 'Content-Type': 'application/json' }
}
);
} catch (error) {
console.error('❌ Error in reimbursement email notification API:', error);
return new Response(
JSON.stringify({
error: 'Internal server error',
details: error instanceof Error ? error.message : 'Unknown error'
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};
// Helper functions for reimbursement email types
async function sendStatusChangeEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
try {
console.log('📧 Starting reimbursement status change email process...');
console.log('Environment check:', {
hasResendKey: !!import.meta.env.RESEND_API_KEY,
fromEmail,
replyToEmail,
pocketbaseUrl: import.meta.env.POCKETBASE_URL
});
// Check if this is a test scenario
const isTestData = data.reimbursementId?.includes('test') || data.reimbursementId === 'test-id';
let reimbursement, user;
if (isTestData) {
console.log('🧪 Using test data for demonstration');
// Use mock data for testing
reimbursement = {
id: data.reimbursementId,
title: 'Test Reimbursement Request',
total_amount: 125.50,
date_of_purchase: new Date().toISOString(),
department: 'general',
payment_method: 'Personal Card',
status: data.previousStatus || 'submitted',
submitted_by: 'test-user-id',
audit_notes: ''
};
user = {
id: 'test-user-id',
name: 'Test User',
email: data.additionalContext?.testEmail || 'test@example.com'
};
console.log('✅ Test data prepared:', {
reimbursementTitle: reimbursement.title,
userEmail: user.email
});
} else {
// Get real reimbursement details
console.log('🔍 Fetching reimbursement details for:', data.reimbursementId);
reimbursement = await pb.collection('reimbursement').getOne(data.reimbursementId);
console.log('✅ Reimbursement fetched:', { id: reimbursement.id, title: reimbursement.title });
// Get submitter user details
console.log('👤 Fetching user details for:', reimbursement.submitted_by);
user = await pb.collection('users').getOne(reimbursement.submitted_by);
if (!user || !user.email) {
console.error('❌ User not found or no email:', reimbursement.submitted_by);
return false;
}
console.log('✅ User fetched:', { id: user.id, name: user.name, email: user.email });
}
// Get changed by user name if provided
let changedByName = 'System';
if (data.changedByUserId) {
try {
const changedByUser = await pb.collection('users').getOne(data.changedByUserId);
changedByName = changedByUser?.name || 'Unknown User';
console.log('👤 Changed by user:', changedByName);
} catch (error) {
console.warn('⚠️ Could not get changed by user name:', error);
}
}
const subject = `Reimbursement Status Updated: ${reimbursement.title}`;
const statusColor = getStatusColor(data.newStatus);
const statusText = getStatusText(data.newStatus);
console.log('📝 Email details:', {
to: user.email,
subject,
status: data.newStatus
});
// Add audit note when reimbursement is declined (skip for test data)
if (data.newStatus === 'rejected' && !isTestData) {
try {
console.log('📝 Adding audit note for declined reimbursement...');
// Prepare audit note content
let auditNote = `Status changed to REJECTED by ${changedByName}`;
if (data.additionalContext?.rejectionReason) {
auditNote += `\nRejection Reason: ${data.additionalContext.rejectionReason}`;
}
auditNote += `\nDate: ${new Date().toLocaleString()}`;
// Get existing audit notes or initialize empty string
const existingNotes = reimbursement.audit_notes || '';
const updatedNotes = existingNotes
? `${existingNotes}\n\n--- DECLINE RECORD ---\n${auditNote}`
: `--- DECLINE RECORD ---\n${auditNote}`;
// Update the reimbursement record with the new audit notes
await pb.collection('reimbursement').update(data.reimbursementId, {
audit_notes: updatedNotes
});
console.log('✅ Audit note added successfully for declined reimbursement');
} catch (auditError) {
console.error('❌ Failed to add audit note for declined reimbursement:', auditError);
// Don't fail the entire email process if audit note fails
}
} else if (data.newStatus === 'rejected' && isTestData) {
console.log('🧪 Skipping audit note update for test data');
}
// Helper function to generate status progress bar HTML (email-compatible)
function generateStatusProgressBar(currentStatus: string): string {
const statusOrder = ['submitted', 'under_review', 'approved', 'in_progress', 'paid'];
const rejectedStatus = ['submitted', 'under_review', 'rejected'];
const isRejected = currentStatus === 'rejected';
const statuses = isRejected ? rejectedStatus : statusOrder;
const statusIcons: Record<string, string> = {
submitted: '→',
under_review: '?',
approved: '✓',
rejected: '✗',
in_progress: '○',
paid: '$'
};
const statusLabels: Record<string, string> = {
submitted: 'Submitted',
under_review: 'Under Review',
approved: 'Approved',
rejected: 'Rejected',
in_progress: 'In Progress',
paid: 'Paid'
};
const currentIndex = statuses.indexOf(currentStatus);
let progressBarHtml = `
<div style="background: #f8fafc; padding: 30px 20px; border-radius: 8px; margin: 20px 0; border: 1px solid #e2e8f0;">
<h3 style="margin: 0 0 30px 0; color: #1e293b; font-size: 16px; font-weight: 600; text-align: center;">Request Progress</h3>
<table style="width: 100%; max-width: 500px; margin: 0 auto; border-collapse: collapse; position: relative;">
<tr style="position: relative;">
<td colspan="${statuses.length * 2 - 1}" style="height: 2px; background: #e2e8f0; position: absolute; top: 21px; left: 0; right: 0; z-index: 3;"></td>
</tr>
<tr style="position: relative; z-index: 1;">
`;
statuses.forEach((status, index) => {
const isActive = index <= currentIndex;
const isCurrent = status === currentStatus;
let backgroundColor, textColor, lineColor;
if (isCurrent) {
if (status === 'rejected') {
backgroundColor = '#ef4444';
textColor = 'white';
lineColor = '#ef4444';
} else if (status === 'paid') {
backgroundColor = '#10b981';
textColor = 'white';
lineColor = '#10b981';
} else if (status === 'in_progress') {
backgroundColor = '#f59e0b';
textColor = 'white';
lineColor = '#f59e0b';
} else {
backgroundColor = '#3b82f6';
textColor = 'white';
lineColor = '#3b82f6';
}
} else if (isActive) {
backgroundColor = '#e2e8f0';
textColor = '#475569';
lineColor = '#cbd5e1';
} else {
backgroundColor = '#f8fafc';
textColor = '#94a3b8';
lineColor = '#e2e8f0';
}
// Status circle
progressBarHtml += `
<td style="text-align: center; padding: 0; vertical-align: top; position: relative; width: ${100/statuses.length}%;">
<div style="position: relative; z-index: 1; padding: 5px 0;">
<div style="
width: 32px;
height: 32px;
border-radius: 50%;
background: ${backgroundColor};
color: ${textColor};
text-align: center;
line-height: 32px;
font-size: 16px;
font-weight: bold;
border: 3px solid #f8fafc;
box-shadow: none;
margin: 0 auto 8px auto;
">
${statusIcons[status]}
</div>
<div style="
font-size: 11px;
font-weight: 600;
color: ${isCurrent ? (status === 'rejected' ? '#ef4444' : status === 'paid' ? '#10b981' : status === 'in_progress' ? '#f59e0b' : '#3b82f6') : isActive ? '#475569' : '#94a3b8'};
text-align: center;
line-height: 1.2;
white-space: nowrap;
">
${statusLabels[status]}
</div>
</div>
</td>
`;
// Connecting line (except for the last status)
if (index < statuses.length - 1) {
const nextIsActive = (index + 1) <= currentIndex;
const connectionColor = nextIsActive ? lineColor : '#e2e8f0';
progressBarHtml += `
<td style="padding: 0; vertical-align: top; position: relative; width: 20px;">
<div style="
height: 2px;
background: ${connectionColor};
position: absolute;
top: 21px;
left: 0;
right: 0;
z-index: 3;
"></div>
</td>
`;
}
});
progressBarHtml += `
</tr>
</table>
</div>
`;
return progressBarHtml;
}
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Update</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">Status Update</h2>
<p>Hello ${user.name},</p>
<p>Your reimbursement request "<strong>${reimbursement.title}</strong>" has been updated.</p>
${data.useImageProgress ?
`<div style="text-align: center; margin: 20px 0;">
<img src="${getStatusImageUrl(data.newStatus, 'https://ieeeatucsd.org')}" alt="Request Progress" style="max-width: 100%; height: auto; border-radius: 8px;" />
</div>` :
generateStatusProgressBar(data.newStatus)
}
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${statusColor}; margin: 20px 0;">
<div style="margin-bottom: 15px;">
<span style="font-weight: bold; color: #666;">Status:</span>
<span style="background: ${statusColor}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500; margin-left: 10px;">${statusText}</span>
</div>
${data.previousStatus && data.previousStatus !== data.newStatus ? `
<div style="color: #666; font-size: 14px;">
Changed from: <span style="text-decoration: line-through;">${getStatusText(data.previousStatus)}</span> <strong>${statusText}</strong>
</div>
` : ''}
${changedByName !== 'System' ? `
<div style="color: #666; font-size: 14px; margin-top: 10px;">
Updated by: ${changedByName}
</div>
` : ''}
${data.newStatus === 'rejected' && data.additionalContext?.rejectionReason ? `
<div style="background: #f8d7da; padding: 15px; border-radius: 6px; border: 1px solid #f5c6cb; margin-top: 15px;">
<div style="font-weight: bold; color: #721c24; margin-bottom: 8px;">Rejection Reason:</div>
<div style="color: #721c24; font-style: italic;">${data.additionalContext.rejectionReason}</div>
</div>
` : ''}
</div>
<div style="margin: 25px 0;">
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Amount:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Payment Method:</td>
<td style="padding: 8px 0;">${reimbursement.payment_method}</td>
</tr>
</table>
</div>
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;">
<p style="margin: 0; font-size: 14px;"><strong>Next Steps:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
${getNextStepsText(data.newStatus)}
</p>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
</div>
</body>
</html>
`;
console.log('Attempting to send email via Resend...');
const result = await resend.emails.send({
from: fromEmail,
to: [user.email],
replyTo: replyToEmail,
subject,
html,
});
console.log('Resend response:', result);
console.log('Status change email sent successfully!');
return true;
} catch (error) {
console.error('Failed to send status change email:', error);
console.error('Error details:', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
return false;
}
}
async function sendCommentEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
try {
console.log('Starting comment email process...');
console.log('Comment data received:', {
reimbursementId: data.reimbursementId,
commentByUserId: data.commentByUserId,
isPrivate: data.isPrivate,
commentLength: data.comment?.length || 0
});
// Don't send emails for private comments
if (data.isPrivate) {
console.log('Comment is private, skipping email notification');
return true;
}
// Get reimbursement details
console.log('Fetching reimbursement details for:', data.reimbursementId);
const reimbursement = await pb.collection('reimbursement').getOne(data.reimbursementId);
console.log('Reimbursement fetched:', {
id: reimbursement.id,
title: reimbursement.title,
submitted_by: reimbursement.submitted_by
});
// Get submitter user details
console.log('Fetching submitter user details for:', reimbursement.submitted_by);
const user = await pb.collection('users').getOne(reimbursement.submitted_by);
if (!user || !user.email) {
console.error('User not found or no email:', reimbursement.submitted_by);
return false;
}
console.log('Submitter user fetched:', {
id: user.id,
name: user.name,
email: user.email
});
// Get commenter user name
console.log('Fetching commenter user details for:', data.commentByUserId);
let commentByName = 'Unknown User';
try {
const commentByUser = await pb.collection('users').getOne(data.commentByUserId);
commentByName = commentByUser?.name || 'Unknown User';
console.log('Commenter user fetched:', {
id: commentByUser?.id,
name: commentByName
});
} catch (error) {
console.warn('Could not get commenter user name:', error);
}
const subject = `New Comment on Reimbursement: ${reimbursement.title}`;
console.log('Comment email details:', {
to: user.email,
subject,
commentBy: commentByName,
commentPreview: data.comment.substring(0, 50) + (data.comment.length > 50 ? '...' : '')
});
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Comment</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">New Comment Added</h2>
<p>Hello ${user.name},</p>
<p>A new comment has been added to your reimbursement request "<strong>${reimbursement.title}</strong>".</p>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #3498db; margin: 20px 0;">
<div style="margin-bottom: 15px;">
<span style="font-weight: bold; color: #2980b9;">Comment by:</span> ${commentByName}
</div>
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px;">
<p style="margin: 0; font-style: italic;">${data.comment}</p>
</div>
</div>
<div style="margin: 25px 0;">
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Status:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">
<span style="background: ${getStatusColor(reimbursement.status)}; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
${getStatusText(reimbursement.status)}
</span>
</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Date of Purchase:</td>
<td style="padding: 8px 0;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
</tr>
</table>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
</div>
</body>
</html>
`;
console.log('Attempting to send comment email via Resend...');
const result = await resend.emails.send({
from: fromEmail,
to: [user.email],
replyTo: replyToEmail,
subject,
html,
});
console.log('Resend comment email response:', result);
console.log('Comment email sent successfully!');
return true;
} catch (error) {
console.error('Failed to send comment email:', error);
console.error('Comment email error details:', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
return false;
}
}
async function sendSubmissionEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
try {
// Get reimbursement details
const reimbursement = await pb.collection('reimbursement').getOne(data.reimbursementId);
// Get submitter user details
const user = await pb.collection('users').getOne(reimbursement.submitted_by);
if (!user || !user.email) {
console.error('User not found or no email:', reimbursement.submitted_by);
return false;
}
// Send confirmation email to submitter
const submitterSubject = `Reimbursement Submitted: ${reimbursement.title}`;
const submitterHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${submitterSubject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">Reimbursement Submitted Successfully</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">Submission Confirmed</h2>
<p>Hello ${user.name},</p>
<p>Your reimbursement request has been successfully submitted and is now under review.</p>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
<h3 style="margin-top: 0; color: #155724;">Reimbursement Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Title:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.title}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Payment Method:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.payment_method}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Status:</td>
<td style="padding: 8px 0;">
<span style="background: #ffc107; color: #212529; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
Submitted
</span>
</td>
</tr>
</table>
</div>
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
<h4 style="margin: 0 0 10px 0; color: #155724;">What happens next?</h4>
<ul style="margin: 0; padding-left: 20px; color: #155724;">
<li>Your receipts will be reviewed by our team</li>
<li>You'll receive email updates as the status changes</li>
<li>Once approved, payment will be processed</li>
<li>Typical processing time is 1-2 weeks</li>
</ul>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
</div>
</body>
</html>
`;
// Send notification email to treasurer
const treasurerSubject = `New Reimbursement Request: ${reimbursement.title} - $${reimbursement.total_amount.toFixed(2)}`;
const treasurerHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${treasurerSubject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">New Reimbursement Request</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">Action Required</h2>
<p>Hello Treasurer,</p>
<p>A new reimbursement request has been submitted and is awaiting review.</p>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #007bff; margin: 20px 0;">
<h3 style="margin-top: 0; color: #004085;">Reimbursement Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Submitted by:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${user.name} (${user.email})</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Title:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.title}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; color: #28a745;">$${reimbursement.total_amount.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Payment Method:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.payment_method}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Submitted:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.created).toLocaleString()}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Status:</td>
<td style="padding: 8px 0;">
<span style="background: #ffc107; color: #212529; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
Submitted - Awaiting Review
</span>
</td>
</tr>
</table>
${reimbursement.additional_info ? `
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #eee;">
<h4 style="margin: 0 0 10px 0; color: #495057;">Additional Information:</h4>
<div style="background: #f8f9fa; padding: 12px; border-radius: 6px; font-style: italic;">
${reimbursement.additional_info}
</div>
</div>
` : ''}
</div>
<div style="background: #e7f3ff; padding: 15px; border-radius: 8px; border-left: 4px solid #007bff; margin: 20px 0;">
<h4 style="margin: 0 0 10px 0; color: #004085;">Next Steps:</h4>
<ul style="margin: 0; padding-left: 20px; color: #004085;">
<li>Review the submitted receipts and documentation</li>
<li>Log into the reimbursement portal to approve or request changes</li>
<li>The submitter will be notified of any status updates</li>
</ul>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
<p>If you have any questions, please contact the submitter directly at <a href="mailto:${user.email}" style="color: #667eea;">${user.email}</a></p>
</div>
</body>
</html>
`;
// Send both emails
const submitterResult = await resend.emails.send({
from: fromEmail,
to: [user.email],
replyTo: replyToEmail,
subject: submitterSubject,
html: submitterHtml,
});
const treasurerResult = await resend.emails.send({
from: fromEmail,
to: ['treasurer@ieeeatucsd.org'],
replyTo: user.email, // Set reply-to as the submitter for treasurer's convenience
subject: treasurerSubject,
html: treasurerHtml,
});
console.log('Submission confirmation email sent successfully:', submitterResult);
console.log('Treasurer notification email sent successfully:', treasurerResult);
// Return true if at least one email was sent successfully
return !!(submitterResult && treasurerResult);
} catch (error) {
console.error('Failed to send submission emails:', error);
return false;
}
}
async function sendTestEmail(resend: any, fromEmail: string, replyToEmail: string, email: string): Promise<boolean> {
try {
console.log('Starting test email process...');
console.log('Test email configuration:', {
fromEmail,
replyToEmail,
toEmail: email,
hasResend: !!resend
});
const subject = 'Test Email from IEEE UCSD Reimbursement System';
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">Test Email</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">Email System Test</h2>
<p>This is a test email from the IEEE UCSD Reimbursement System.</p>
<p>If you receive this email, the notification system is working correctly!</p>
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
<p style="margin: 0; color: #155724;">Email delivery successful</p>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is a test notification from IEEE UCSD Reimbursement System.</p>
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
</div>
</body>
</html>
`;
console.log('Sending test email via Resend...');
const result = await resend.emails.send({
from: fromEmail,
to: [email],
replyTo: replyToEmail,
subject,
html,
});
console.log('Resend test email response:', result);
console.log('Test email sent successfully!');
return true;
} catch (error) {
console.error('Failed to send test email:', error);
console.error('Test email error details:', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
return false;
}
}

View file

@ -0,0 +1,98 @@
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
try {
console.log('📨 Email notification API called (legacy endpoint)');
const requestData = await request.json();
const { type, reimbursementId, eventRequestId } = requestData;
console.log('📋 Request data:', {
type,
reimbursementId,
eventRequestId
});
if (!type) {
console.error('❌ Missing required parameter: type');
return new Response(
JSON.stringify({ error: 'Missing required parameter: type' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Determine which endpoint to redirect to based on email type
const reimbursementTypes = ['status_change', 'comment', 'submission', 'test'];
const eventRequestTypes = ['event_request_submission', 'event_request_status_change', 'pr_completed', 'design_pr_notification'];
const officerTypes = ['officer_role_change'];
let targetEndpoint = '';
if (reimbursementTypes.includes(type)) {
if (!reimbursementId && type !== 'test') {
return new Response(
JSON.stringify({ error: 'Missing reimbursementId for reimbursement notification' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
targetEndpoint = '/api/email/send-reimbursement-email';
} else if (eventRequestTypes.includes(type)) {
if (!eventRequestId) {
return new Response(
JSON.stringify({ error: 'Missing eventRequestId for event request notification' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
targetEndpoint = '/api/email/send-event-request-email';
} else if (officerTypes.includes(type)) {
const { officerId } = requestData;
if (!officerId) {
return new Response(
JSON.stringify({ error: 'Missing officerId for officer notification' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
targetEndpoint = '/api/email/send-officer-notification';
} else {
console.error('❌ Unknown notification type:', type);
return new Response(
JSON.stringify({ error: `Unknown notification type: ${type}` }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
console.log(`🔄 Redirecting ${type} to ${targetEndpoint}`);
// Forward the request to the appropriate endpoint
const baseUrl = new URL(request.url).origin;
const response = await fetch(`${baseUrl}${targetEndpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestData),
});
const result = await response.json();
console.log(`📊 Forwarded request result: ${result.success ? 'SUCCESS' : 'FAILED'}`);
return new Response(
JSON.stringify(result),
{
status: response.status,
headers: { 'Content-Type': 'application/json' }
}
);
} catch (error) {
console.error('❌ Error in email notification API:', error);
return new Response(
JSON.stringify({
error: 'Internal server error',
details: error instanceof Error ? error.message : 'Unknown error'
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View file

@ -0,0 +1,258 @@
import type { APIRoute } from 'astro';
import puppeteer from 'puppeteer';
export const GET: APIRoute = async ({ url }) => {
try {
const searchParams = new URL(url).searchParams;
const status = searchParams.get('status') || 'submitted';
const width = parseInt(searchParams.get('width') || '500');
const height = parseInt(searchParams.get('height') || '150');
console.log('🎨 Generating SVG status image for:', { status, width, height });
// Generate SVG status progress bar
function generateSVGProgressBar(currentStatus: string): string {
const statusOrder = ['submitted', 'under_review', 'approved', 'in_progress', 'paid'];
const rejectedStatus = ['submitted', 'under_review', 'rejected'];
const isRejected = currentStatus === 'rejected';
const statuses = isRejected ? rejectedStatus : statusOrder;
const statusIcons: Record<string, string> = {
submitted: '→',
under_review: '?',
approved: '✓',
rejected: '✗',
in_progress: '○',
paid: '$'
};
const statusLabels: Record<string, string> = {
submitted: 'Submitted',
under_review: 'Under Review',
approved: 'Approved',
rejected: 'Rejected',
in_progress: 'In Progress',
paid: 'Paid'
};
const currentIndex = statuses.indexOf(currentStatus);
const circleRadius = 44; // Double for 2x resolution
const lineY = height;
const totalWidth = width * 1.6; // Double for 2x resolution (80% of doubled width)
const startX = width * 0.2; // Double for 2x resolution (10% of doubled width)
const stepWidth = totalWidth / (statuses.length - 1);
let svgElements = '';
// Generate background line (behind circles) - doubled for 2x resolution
svgElements += `<line x1="${startX}" y1="${lineY + 2}" x2="${startX + totalWidth}" y2="${lineY + 2}" stroke="#e2e8f0" stroke-width="8" opacity="0.6"/>`;
// Generate progress line up to current status
if (currentIndex >= 0) {
const progressEndX = startX + (currentIndex * stepWidth);
let progressColor = '#3b82f6'; // Default blue
// Set progress color based on current status
if (currentStatus === 'rejected') {
progressColor = '#ef4444';
} else if (currentStatus === 'paid') {
progressColor = '#10b981';
} else if (currentStatus === 'in_progress') {
progressColor = '#f59e0b';
}
svgElements += `<line x1="${startX}" y1="${lineY + 2}" x2="${progressEndX}" y2="${lineY + 2}" stroke="${progressColor}" stroke-width="6" opacity="0.9"/>`;
}
// Generate status circles and labels
statuses.forEach((statusName, index) => {
const x = startX + (index * stepWidth);
const y = lineY;
const isActive = index <= currentIndex;
const isCurrent = statusName === currentStatus;
let backgroundColor, textColor;
if (isCurrent) {
if (statusName === 'rejected') {
backgroundColor = '#ef4444';
textColor = 'white';
} else if (statusName === 'paid') {
backgroundColor = '#10b981';
textColor = 'white';
} else if (statusName === 'in_progress') {
backgroundColor = '#f59e0b';
textColor = 'white';
} else {
backgroundColor = '#3b82f6';
textColor = 'white';
}
} else if (isActive) {
backgroundColor = '#e2e8f0';
textColor = '#475569';
} else {
backgroundColor = '#f8fafc';
textColor = '#94a3b8';
}
const labelColor = isCurrent ?
(statusName === 'rejected' ? '#ef4444' :
statusName === 'paid' ? '#10b981' :
statusName === 'in_progress' ? '#f59e0b' : '#3b82f6') :
isActive ? '#475569' : '#94a3b8';
// Circle with shadow effect
svgElements += `<circle cx="${x}" cy="${y}" r="${circleRadius}" fill="${backgroundColor}" stroke="white" stroke-width="6" filter="url(#shadow)"/>`;
// Icon text - properly centered with dominant-baseline (doubled font size)
svgElements += `<text x="${x}" y="${y}" text-anchor="middle" dominant-baseline="central" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="36" font-weight="bold" fill="${textColor}">${statusIcons[statusName]}</text>`;
// Label text (doubled font size)
svgElements += `<text x="${x}" y="${y + circleRadius + 36}" text-anchor="middle" dominant-baseline="central" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="22" font-weight="600" fill="${labelColor}">${statusLabels[statusName]}</text>`;
});
return `
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
text {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
</style>
<!-- Drop shadow filter -->
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="4" stdDeviation="6" flood-opacity="0.3"/>
</filter>
</defs>
<!-- Title (doubled font size) -->
<text x="${width}" y="50" text-anchor="middle" dominant-baseline="central" font-size="32" font-weight="700" fill="#1e293b">Request Progress</text>
${svgElements}
</svg>
`;
}
const svg = generateSVGProgressBar(status);
console.log('✅ SVG generated, converting to PNG with Puppeteer...');
// Convert SVG to PNG using Puppeteer
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage', // Overcome limited resource problems
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--no-zygote',
'--single-process', // For limited memory environments
'--disable-gpu',
'--disable-web-security',
'--disable-features=VizDisplayCompositor',
'--disable-extensions',
'--disable-default-apps',
'--disable-sync',
'--no-default-browser-check',
'--force-device-scale-factor=2' // Higher DPI for better quality
],
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, // Allow custom Chromium path
});
console.log('🚀 Puppeteer browser launched successfully');
const page = await browser.newPage();
// Set high-resolution viewport for better quality
await page.setViewport({
width: width * 2, // Double resolution for crisp images
height: height * 2,
deviceScaleFactor: 2
});
console.log('📄 Setting SVG content...');
// Create HTML wrapper for the SVG
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
margin: 0;
padding: 0;
background: transparent;
width: ${width * 2}px;
height: ${height * 2}px;
display: flex;
align-items: center;
justify-content: center;
}
svg {
width: ${width * 2}px;
height: ${height * 2}px;
}
</style>
</head>
<body>
${svg.replace(`width="${width}" height="${height}"`, `width="${width * 2}" height="${height * 2}"`)}
</body>
</html>
`;
// Set HTML content
await page.setContent(html, { waitUntil: 'networkidle0' });
console.log('📸 Taking screenshot...');
// Take high-quality screenshot with transparent background
const screenshot = await page.screenshot({
type: 'png',
fullPage: false,
omitBackground: true, // Transparent background
clip: {
x: 0,
y: 0,
width: width * 2,
height: height * 2
}
});
await browser.close();
console.log('🔒 Browser closed successfully');
console.log('✅ PNG image generated successfully from SVG');
return new Response(screenshot, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600',
},
});
} catch (error) {
console.error('❌ Error generating SVG status image:', error);
console.error('Error details:', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
// Return more detailed error information
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
return new Response(
JSON.stringify({
error: 'Failed to generate status image',
details: errorMessage,
timestamp: new Date().toISOString()
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
);
}
};

82
src/pages/api/logout.ts Normal file
View file

@ -0,0 +1,82 @@
import type { APIRoute } from "astro";
// Mark this endpoint as server-rendered, not static
export const prerender = false;
export const GET: APIRoute = async ({ request, redirect }) => {
try {
// Get the Logto endpoint and client ID from environment variables
const logtoEndpoint = import.meta.env.LOGTO_ENDPOINT;
const clientId = import.meta.env.LOGTO_POCKETBASE_APP_ID;
if (!logtoEndpoint) {
throw new Error("LOGTO_ENDPOINT environment variable is not set");
}
if (!clientId) {
throw new Error(
"LOGTO_POCKETBASE_APP_ID environment variable is not set",
);
}
// Get the current origin to use as the redirect URL
const url = new URL(request.url);
const origin = url.origin;
// Construct the redirect URL (back to dashboard)
const redirectUrl = `${origin}/dashboard`;
// Log the redirect URL for debugging
console.log(`Setting post-logout redirect to: ${redirectUrl}`);
console.log(`Using client ID: ${clientId}`);
// Make a POST request to the Logto session end endpoint with the redirect in the body
const logoutUrl = `${logtoEndpoint}/oidc/session/end`;
console.log(`Using Logto endpoint: ${logtoEndpoint}`);
console.log(`Full logout URL: ${logoutUrl}`);
try {
// Try to make a POST request with the redirect in the body and client ID
const response = await fetch(logoutUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
post_logout_redirect_uri: redirectUrl,
client_id: clientId,
}),
redirect: "manual", // Don't automatically follow redirects
});
// If we get a redirect response, follow it
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("Location");
if (location) {
console.log(`Received redirect to: ${location}`);
return redirect(location);
}
}
// If POST doesn't work, fall back to the query parameter approach
console.log(
"POST request didn't result in expected redirect, falling back to GET",
);
return redirect(
`${logoutUrl}?post_logout_redirect_uri=${encodeURIComponent(redirectUrl)}&client_id=${encodeURIComponent(clientId)}`,
);
} catch (fetchError) {
console.error("Error making POST request to Logto:", fetchError);
// Fall back to the query parameter approach
return redirect(
`${logoutUrl}?post_logout_redirect_uri=${encodeURIComponent(redirectUrl)}&client_id=${encodeURIComponent(clientId)}`,
);
}
} catch (error) {
console.error("Error in logout API:", error);
// If there's an error, redirect to dashboard anyway
return redirect("/dashboard");
}
};

View file

@ -9,10 +9,8 @@ import { SendLog } from "../scripts/pocketbase/SendLog";
import { hasAccess, type OfficerStatus } from "../utils/roleAccess"; import { hasAccess, type OfficerStatus } from "../utils/roleAccess";
import { OfficerTypes } from "../schemas/pocketbase/schema"; import { OfficerTypes } from "../schemas/pocketbase/schema";
import { initAuthSync } from "../scripts/database/initAuthSync"; import { initAuthSync } from "../scripts/database/initAuthSync";
import { initTheme } from "../scripts/database/initTheme";
import ToastProvider from "../components/dashboard/universal/ToastProvider"; import ToastProvider from "../components/dashboard/universal/ToastProvider";
import FirstTimeLoginManager from "../components/dashboard/universal/FirstTimeLoginManager"; import FirstTimeLoginManager from "../components/dashboard/universal/FirstTimeLoginManager";
import ThemeToggle from "../components/dashboard/universal/ThemeToggle";
const title = "Dashboard"; const title = "Dashboard";
@ -42,7 +40,7 @@ const components = Object.fromEntries(
--- ---
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -51,10 +49,6 @@ const components = Object.fromEntries(
<script <script
src="https://code.iconify.design/iconify-icon/2.3.0/iconify-icon.min.js" src="https://code.iconify.design/iconify-icon/2.3.0/iconify-icon.min.js"
></script> ></script>
<script is:inline>
// Set a default theme until IndexedDB loads
document.documentElement.setAttribute("data-theme", "dark");
</script>
</head> </head>
<body class="bg-base-200"> <body class="bg-base-200">
<!-- First Time Login Manager - This handles the onboarding popup for new users --> <!-- First Time Login Manager - This handles the onboarding popup for new users -->
@ -157,8 +151,12 @@ const components = Object.fromEntries(
</div> </div>
<!-- Navigation --> <!-- Navigation -->
<nav class="flex-1 overflow-y-auto scrollbar-hide py-6"> <nav
<ul class="menu gap-2 px-4 text-base-content/80"> class="flex-1 overflow-y-auto scrollbar-hide py-6 flex flex-col"
>
<ul
class="menu gap-2 px-4 text-base-content/80 flex-1 flex flex-col"
>
<!-- Loading Skeleton --> <!-- Loading Skeleton -->
<div id="menuLoadingSkeleton"> <div id="menuLoadingSkeleton">
{ {
@ -244,6 +242,20 @@ const components = Object.fromEntries(
) )
) )
} }
{/* Add Logout Button to the bottom of the menu */}
<li class="mt-auto">
<button
class="dashboard-nav-btn gap-4 transition-all duration-200 outline-none focus:outline-none hover:bg-opacity-5 text-error"
data-section="logout"
>
<Icon
name="heroicons:arrow-left-on-rectangle"
class="h-5 w-5"
/>
Logout
</button>
</li>
</div> </div>
</ul> </ul>
</nav> </nav>
@ -393,7 +405,6 @@ const components = Object.fromEntries(
import { hasAccess, type OfficerStatus } from "../utils/roleAccess"; import { hasAccess, type OfficerStatus } from "../utils/roleAccess";
import { OfficerTypes } from "../schemas/pocketbase/schema"; import { OfficerTypes } from "../schemas/pocketbase/schema";
import { initAuthSync } from "../scripts/database/initAuthSync"; import { initAuthSync } from "../scripts/database/initAuthSync";
import { initTheme } from "../scripts/database/initTheme";
const auth = Authentication.getInstance(); const auth = Authentication.getInstance();
const get = Get.getInstance(); const get = Get.getInstance();
@ -404,11 +415,6 @@ const components = Object.fromEntries(
window.toast = () => {}; window.toast = () => {};
} }
// Initialize theme from IndexedDB
initTheme().catch((err) =>
console.error("Error initializing theme:", err)
);
// Initialize page state // Initialize page state
const pageLoadingState = const pageLoadingState =
document.getElementById("pageLoadingState"); document.getElementById("pageLoadingState");
@ -469,6 +475,180 @@ const components = Object.fromEntries(
}); });
}; };
// Function to delete all cookies (to handle Logto logout)
const deleteAllCookies = () => {
// Get all cookies
const cookies = document.cookie.split(";");
// Common paths that might have cookies
const paths = ["/", "/dashboard", "/auth", "/api"];
// Domains to target
const domains = [
"", // current domain
"auth.ieeeucsd.org",
".auth.ieeeucsd.org",
"ieeeucsd.org",
".ieeeucsd.org",
"dev.ieeeucsd.org",
".dev.ieeeucsd.org",
];
// Delete each cookie with all combinations of paths and domains
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i];
const eqPos = cookie.indexOf("=");
const name =
eqPos > -1
? cookie.substring(0, eqPos).trim()
: cookie.trim();
if (!name) continue; // Skip empty cookie names
// Try all combinations of paths and domains
for (const path of paths) {
// Delete from current domain (no domain specified)
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=${path}`;
// Try with specific domains
for (const domain of domains) {
if (domain) {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=${path};domain=${domain}`;
}
}
}
}
// Specifically target known Logto cookies
const logtoSpecificCookies = [
"logto",
"logto.signin",
"logto.session",
"logto.callback",
];
for (const cookieName of logtoSpecificCookies) {
for (const path of paths) {
document.cookie = `${cookieName}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=${path}`;
for (const domain of domains) {
if (domain) {
document.cookie = `${cookieName}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=${path};domain=${domain}`;
}
}
}
}
};
// Function to create and show a logout confirmation modal
const showLogoutConfirmation = () => {
// Create modal if it doesn't exist
let modal = document.getElementById("logoutConfirmModal");
if (!modal) {
modal = document.createElement("dialog");
modal.id = "logoutConfirmModal";
modal.className = "modal modal-bottom sm:modal-middle";
modal.innerHTML = `
<div class="modal-box">
<h3 class="font-bold text-lg">Confirm Logout</h3>
<p class="py-4">Are you sure you want to log out of your account?</p>
<div class="modal-action">
<button id="cancelLogout" class="btn btn-outline">Cancel</button>
<button id="confirmLogout" class="btn btn-error">
<span id="logoutSpinner" class="loading loading-spinner loading-sm hidden"></span>
<span id="logoutText">Log Out</span>
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>Close</button>
</form>
`;
document.body.appendChild(modal);
// Add event listeners
document
.getElementById("cancelLogout")
?.addEventListener("click", () => {
(modal as HTMLDialogElement).close();
});
document
.getElementById("confirmLogout")
?.addEventListener("click", async () => {
// Show loading state
const spinner =
document.getElementById("logoutSpinner");
const text = document.getElementById("logoutText");
const confirmBtn =
document.getElementById("confirmLogout");
const cancelBtn =
document.getElementById("cancelLogout");
if (spinner) spinner.classList.remove("hidden");
if (text) text.textContent = "Logging out...";
if (confirmBtn)
confirmBtn.setAttribute("disabled", "true");
if (cancelBtn)
cancelBtn.setAttribute("disabled", "true");
try {
// Log the logout action
await logger.send(
"logout",
"auth",
"User logged out from dashboard menu"
);
// Log out from PocketBase using the Authentication class
await auth.logout();
// For extra safety, also directly clear the PocketBase auth store
const pb = auth.getPocketBase();
pb.authStore.clear();
// Delete all cookies to ensure Logto is logged out
deleteAllCookies();
// Redirect to our API logout endpoint which will properly sign out from Logto
window.location.href = "/api/logout";
return; // Stop execution here as we're redirecting
} catch (error) {
console.error("Error during logout:", error);
// Show error message if toast is available
if (
window.toast &&
typeof window.toast === "function"
) {
window.toast(
"Failed to log out. Please try again.",
{
type: "error",
}
);
}
// Reset button state
if (spinner) spinner.classList.add("hidden");
if (text) text.textContent = "Log Out";
if (confirmBtn)
confirmBtn.removeAttribute("disabled");
if (cancelBtn)
cancelBtn.removeAttribute("disabled");
// Close the modal
if (modal) (modal as HTMLDialogElement).close();
}
});
}
// Show the modal
if (modal) (modal as HTMLDialogElement).showModal();
};
// Handle navigation // Handle navigation
const handleNavigation = () => { const handleNavigation = () => {
const navButtons = const navButtons =
@ -488,8 +668,7 @@ const components = Object.fromEntries(
// Handle logout button // Handle logout button
if (sectionKey === "logout") { if (sectionKey === "logout") {
auth.logout(); showLogoutConfirmation();
window.location.reload();
return; return;
} }
@ -898,14 +1077,6 @@ const components = Object.fromEntries(
} }
}); });
// Handle logout button click
document
.getElementById("logoutButton")
?.addEventListener("click", () => {
auth.logout();
window.location.reload();
});
// Handle responsive sidebar // Handle responsive sidebar
if (sidebar) { if (sidebar) {
if (window.innerWidth < 1024) { if (window.innerWidth < 1024) {

View file

@ -0,0 +1,15 @@
---
import Layout from "../../layouts/Layout.astro";
import OfficerManagement from "../../components/dashboard/OfficerManagement/OfficerManagement";
const title = "Officer Management";
---
<Layout title={title}>
<main class="p-4 md:p-8 max-w-6xl mx-auto">
<h1 class="text-3xl font-bold mb-6">{title}</h1>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<OfficerManagement client:load />
</div>
</main>
</Layout>

View file

@ -3,15 +3,25 @@ import Layout from "../layouts/Layout.astro";
const title = "Authenticating..."; const title = "Authenticating...";
--- ---
<main class="min-h-screen flex items-center justify-center"> <html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title} | IEEE UCSD</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
</head>
<body class="bg-base-200">
<main class="min-h-screen flex items-center justify-center">
<div id="content" class="text-center"> <div id="content" class="text-center">
<p class="text-2xl font-medium">Redirecting to dashboard...</p> <p class="text-2xl font-medium">Redirecting to dashboard...</p>
<div class="mt-4"> <div class="mt-4">
<div class="loading loading-spinner loading-lg"></div> <div class="loading loading-spinner loading-lg"></div>
</div> </div>
</div> </div>
</main> </main>
<script> <script>
import { RedirectHandler } from "../scripts/auth/RedirectHandler"; import { RedirectHandler } from "../scripts/auth/RedirectHandler";
new RedirectHandler(); new RedirectHandler();
</script> </script>
</body>
</html>

View file

@ -0,0 +1,156 @@
---
// Status Images Display Page
const statuses = [
'submitted',
'under_review',
'approved',
'in_progress',
'paid',
'rejected'
];
const imageWidth = 500;
const imageHeight = 150;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Reimbursement Status Images</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8fafc;
margin: 0;
padding: 40px 20px;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
h1 {
color: #1e293b;
text-align: center;
margin-bottom: 40px;
font-size: 2rem;
font-weight: 700;
}
h2 {
color: #475569;
margin: 40px 0 20px 0;
font-size: 1.5rem;
font-weight: 600;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 10px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 30px;
margin-bottom: 50px;
}
.status-item {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 20px;
background: #f8fafc;
}
.status-title {
font-size: 18px;
font-weight: 600;
color: #475569;
margin-bottom: 15px;
text-transform: capitalize;
}
.status-image {
width: 100%;
height: auto;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: white;
}
.image-url {
margin-top: 10px;
padding: 8px 12px;
background: #f1f5f9;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #64748b;
word-break: break-all;
}
.demo-section {
background: #f8f9fa;
padding: 30px;
border-radius: 12px;
margin: 30px 0;
border: 2px solid #e2e8f0;
}
.email-demo {
background: white;
padding: 30px;
border-radius: 8px;
border: 1px solid #e2e8f0;
margin: 20px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>Reimbursement Status Progress Images</h1>
<div class="demo-section">
<h2>🖼️ PNG Status Progress Images</h2>
<p>High-quality PNG images generated from SVG using Puppeteer with transparent backgrounds:</p>
<div class="status-grid">
{statuses.map((status) => (
<div class="status-item">
<h3 class="status-title">
{status.replace('_', ' ')}
</h3>
<img
src={`/api/generate-status-image?status=${status}&width=${imageWidth}&height=${imageHeight}`}
alt={`Status progress for ${status}`}
class="status-image"
loading="lazy"
/>
<div class="image-url">
🖼️ PNG API: /api/generate-status-image?status={status}
</div>
</div>
))}
</div>
</div>
<div class="demo-section">
<h2>📨 Email Integration Demo</h2>
<p>Here's how the PNG images look when embedded in an email-like environment:</p>
<div class="email-demo" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<h3 style="color: white; margin: 0 0 20px 0;">IEEE UCSD Reimbursement Update</h3>
<p style="color: #f1f5f9; margin-bottom: 20px;">Your reimbursement request has been updated:</p>
<img
src={`/api/generate-status-image?status=approved&width=500&height=150`}
alt="Status progress embedded in email"
style="width: 100%; max-width: 500px; height: auto; border-radius: 8px;"
loading="lazy"
/>
<p style="color: #f1f5f9; margin-top: 20px; font-size: 14px;">
✨ PNG images with transparent backgrounds work perfectly in all email clients
</p>
</div>
</div>
</div>
</body>
</html>

View file

@ -32,7 +32,7 @@ export interface User extends BaseRecord {
major?: string; major?: string;
zelle_information?: string; zelle_information?: string;
last_login?: string; last_login?: string;
points?: number; // Total points earned from events // points?: number; // Total points earned from events (DEPRECATED)
notification_preferences?: string; // JSON string of notification settings notification_preferences?: string; // JSON string of notification settings
display_preferences?: string; // JSON string of display settings (theme, font size, etc.) display_preferences?: string; // JSON string of display settings (theme, font size, etc.)
accessibility_settings?: string; // JSON string of accessibility settings (color blind mode, reduced motion) accessibility_settings?: string; // JSON string of accessibility settings (color blind mode, reduced motion)
@ -41,6 +41,18 @@ export interface User extends BaseRecord {
requested_email?: boolean; // Whether the user has requested an IEEE email address requested_email?: boolean; // Whether the user has requested an IEEE email address
} }
/**
* Limited User Collection
* Represents limited user information for public display
* Collection ID: pbc_2802685943
*/
export interface LimitedUser extends BaseRecord {
name: string;
major: string;
points: string; // JSON string
total_events_attended: string; // JSON string
}
/** /**
* Events Collection * Events Collection
* Represents events created in the system * Represents events created in the system
@ -56,6 +68,7 @@ export interface Event extends BaseRecord {
start_date: string; start_date: string;
end_date: string; end_date: string;
published: boolean; published: boolean;
event_type: string; // social, technical, outreach, professional, projects, other
has_food: boolean; has_food: boolean;
} }
@ -99,13 +112,14 @@ export interface EventRequest extends BaseRecord {
other_flyer_type?: string; other_flyer_type?: string;
flyer_advertising_start_date?: string; flyer_advertising_start_date?: string;
flyer_additional_requests?: string; flyer_additional_requests?: string;
flyers_completed?: boolean; // Track if flyers have been completed by PR team
photography_needed: boolean; photography_needed: boolean;
required_logos?: string[]; // IEEE, AS, HKN, TESC, PIB, TNT, SWE, OTHER required_logos?: string[]; // IEEE, AS, HKN, TESC, PIB, TNT, SWE, OTHER
other_logos?: string[]; // Array of logo IDs other_logos?: string[]; // Array of logo IDs
advertising_format?: string; advertising_format?: string;
will_or_have_room_booking?: boolean; will_or_have_room_booking?: boolean;
expected_attendance?: number; expected_attendance?: number;
room_booking?: string; // signle file room_booking_files?: string[]; // CHANGED: Multiple files instead of single file
as_funding_required: boolean; as_funding_required: boolean;
food_drinks_being_served: boolean; food_drinks_being_served: boolean;
itemized_invoice?: string; // JSON string itemized_invoice?: string; // JSON string
@ -114,6 +128,7 @@ export interface EventRequest extends BaseRecord {
needs_graphics?: boolean; needs_graphics?: boolean;
needs_as_funding?: boolean; needs_as_funding?: boolean;
status: "submitted" | "pending" | "completed" | "declined"; status: "submitted" | "pending" | "completed" | "declined";
declined_reason?: string; // Reason for declining the event request
requested_user?: string; requested_user?: string;
} }
@ -217,6 +232,7 @@ export const Collections = {
REIMBURSEMENTS: "reimbursement", REIMBURSEMENTS: "reimbursement",
RECEIPTS: "receipts", RECEIPTS: "receipts",
SPONSORS: "sponsors", SPONSORS: "sponsors",
LIMITED_USERS: "limitedUser",
}; };
/** /**

View file

@ -106,6 +106,7 @@ export class DataSyncService {
filter: string = "", filter: string = "",
sort: string = "-created", sort: string = "-created",
expand: Record<string, any> | string[] | string = {}, expand: Record<string, any> | string[] | string = {},
detectDeletions: boolean = true,
): Promise<T[]> { ): Promise<T[]> {
// Skip in non-browser environments // Skip in non-browser environments
if (!isBrowser) { if (!isBrowser) {
@ -154,7 +155,7 @@ export class DataSyncService {
} }
} }
// Get data from PocketBase // Get data from PocketBase with expanded relations
const items = await this.get.getAll<T>(collection, filter, sort, { const items = await this.get.getAll<T>(collection, filter, sort, {
expand: normalizedExpand, expand: normalizedExpand,
}); });
@ -169,12 +170,15 @@ export class DataSyncService {
return []; return [];
} }
// Get existing items to handle conflicts // Get existing items to handle conflicts and deletions
const existingItems = await table.toArray(); const existingItems = await table.toArray();
const existingItemsMap = new Map( const existingItemsMap = new Map(
existingItems.map((item) => [item.id, item]), existingItems.map((item) => [item.id, item]),
); );
// Create a set of server item IDs for efficient deletion detection
const serverItemIds = new Set(items.map(item => item.id));
// Handle conflicts and merge changes // Handle conflicts and merge changes
const itemsToStore = await Promise.all( const itemsToStore = await Promise.all(
items.map(async (item) => { items.map(async (item) => {
@ -206,7 +210,43 @@ export class DataSyncService {
}), }),
); );
// Store in IndexedDB // Handle deletions: find items that exist locally but not on server
// Only detect deletions when:
// 1. detectDeletions is true AND
// 2. No filter is applied (full collection sync) OR filter is a user-specific filter
const shouldDetectDeletions = detectDeletions && (!filter || filter.includes('requested_user=') || filter.includes('user='));
if (shouldDetectDeletions) {
const itemsToDelete = existingItems.filter(localItem => {
// For user-specific filters, only delete items that match the filter criteria
// but don't exist on the server
if (filter && filter.includes('requested_user=')) {
// Extract user ID from filter
const userMatch = filter.match(/requested_user="([^"]+)"/);
const userId = userMatch ? userMatch[1] : null;
// Only consider items for deletion if they belong to the same user
if (userId && (localItem as any).requested_user === userId) {
return !serverItemIds.has(localItem.id);
}
return false; // Don't delete items that don't match the user filter
}
// For full collection syncs, delete any item not on the server
return !serverItemIds.has(localItem.id);
});
// Perform deletions
if (itemsToDelete.length > 0) {
// console.log(`Deleting ${itemsToDelete.length} items from ${collection} that no longer exist on server`);
// Delete items that no longer exist on the server
const idsToDelete = itemsToDelete.map(item => item.id);
await table.bulkDelete(idsToDelete);
}
}
// Store/update items from the server
await table.bulkPut(itemsToStore); await table.bulkPut(itemsToStore);
// Update last sync timestamp // Update last sync timestamp
@ -448,6 +488,7 @@ export class DataSyncService {
filter: string = "", filter: string = "",
sort: string = "-created", sort: string = "-created",
expand: Record<string, any> | string[] | string = {}, expand: Record<string, any> | string[] | string = {},
detectDeletions: boolean = true,
): Promise<T[]> { ): Promise<T[]> {
const db = this.dexieService.getDB(); const db = this.dexieService.getDB();
const table = this.getTableForCollection(collection); const table = this.getTableForCollection(collection);
@ -464,7 +505,7 @@ export class DataSyncService {
if (!this.offlineMode && (forceSync || now - lastSync > syncThreshold)) { if (!this.offlineMode && (forceSync || now - lastSync > syncThreshold)) {
try { try {
await this.syncCollection<T>(collection, filter, sort, expand); await this.syncCollection<T>(collection, filter, sort, expand, detectDeletions);
} catch (error) { } catch (error) {
console.error(`Error syncing ${collection}, using cached data:`, error); console.error(`Error syncing ${collection}, using cached data:`, error);
} }

View file

@ -0,0 +1,264 @@
/**
* Client-side helper for sending email notifications via API routes
* This runs in the browser and calls the server-side email API
*/
import { Authentication } from '../pocketbase/Authentication';
interface EmailNotificationRequest {
type: 'status_change' | 'comment' | 'submission' | 'test' | 'event_request_submission' | 'event_request_status_change' | 'pr_completed' | 'design_pr_notification' | 'officer_role_change';
reimbursementId?: string;
eventRequestId?: string;
officerId?: string;
previousStatus?: string;
newStatus?: string;
changedByUserId?: string;
comment?: string;
commentByUserId?: string;
isPrivate?: boolean;
declinedReason?: string;
additionalContext?: Record<string, any>;
authData?: { token: string; model: any };
}
interface EmailNotificationResponse {
success: boolean;
message: string;
error?: string;
details?: string;
}
export class EmailClient {
private static getAuthData(): { token: string; model: any } | null {
try {
const auth = Authentication.getInstance();
const token = auth.getAuthToken();
const model = auth.getCurrentUser();
if (token && model) {
return { token, model };
}
return null;
} catch (error) {
console.warn('Could not get auth data:', error);
return null;
}
}
private static async sendEmailNotification(request: EmailNotificationRequest): Promise<boolean> {
try {
const authData = this.getAuthData();
const requestWithAuth = {
...request,
authData
};
const response = await fetch('/api/email/send-reimbursement-notification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestWithAuth),
});
const result: EmailNotificationResponse = await response.json();
if (!response.ok) {
console.error('Email notification API error:', result.error || result.message);
return false;
}
return result.success;
} catch (error) {
console.error('Failed to send email notification:', error);
return false;
}
}
private static async sendOfficerNotification(request: EmailNotificationRequest): Promise<boolean> {
try {
const authData = this.getAuthData();
const requestWithAuth = {
...request,
authData
};
const response = await fetch('/api/email/send-officer-notification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestWithAuth),
});
const result: EmailNotificationResponse = await response.json();
if (!response.ok) {
console.error('Officer notification API error:', result.error || result.message);
return false;
}
return result.success;
} catch (error) {
console.error('Failed to send officer notification:', error);
return false;
}
}
/**
* Send status change notification
*/
static async notifyStatusChange(
reimbursementId: string,
newStatus: string,
previousStatus?: string,
changedByUserId?: string,
additionalContext?: Record<string, any>
): Promise<boolean> {
return this.sendEmailNotification({
type: 'status_change',
reimbursementId,
newStatus,
previousStatus,
changedByUserId,
additionalContext
});
}
/**
* Send comment notification
*/
static async notifyComment(
reimbursementId: string,
comment: string,
commentByUserId: string,
isPrivate: boolean = false
): Promise<boolean> {
return this.sendEmailNotification({
type: 'comment',
reimbursementId,
comment,
commentByUserId,
isPrivate
});
}
/**
* Send submission confirmation
*/
static async notifySubmission(reimbursementId: string): Promise<boolean> {
return this.sendEmailNotification({
type: 'submission',
reimbursementId
});
}
/**
* Send rejection notification with reason
*/
static async notifyRejection(
reimbursementId: string,
rejectionReason: string,
previousStatus?: string,
changedByUserId?: string
): Promise<boolean> {
return this.sendEmailNotification({
type: 'status_change',
reimbursementId,
newStatus: 'rejected',
previousStatus,
changedByUserId,
additionalContext: { rejectionReason }
});
}
/**
* Send test email
*/
static async sendTestEmail(): Promise<boolean> {
return this.sendEmailNotification({
type: 'test',
reimbursementId: 'test' // Required but not used for test emails
});
}
/**
* Send event request submission notification to coordinators
*/
static async notifyEventRequestSubmission(eventRequestId: string): Promise<boolean> {
return this.sendEmailNotification({
type: 'event_request_submission',
eventRequestId
});
}
/**
* Send email notification when an event request status is changed
*/
static async notifyEventRequestStatusChange(
eventRequestId: string,
previousStatus: string,
newStatus: string,
changedByUserId?: string,
declinedReason?: string
): Promise<boolean> {
return this.sendEmailNotification({
type: 'event_request_status_change',
eventRequestId,
previousStatus,
newStatus,
changedByUserId,
declinedReason
});
}
/**
* Send email notification when PR work is completed for an event request
*/
static async notifyPRCompleted(eventRequestId: string): Promise<boolean> {
return this.sendEmailNotification({
type: 'pr_completed',
eventRequestId
});
}
/**
* Send email notification to design team for PR-related actions
*/
static async notifyDesignTeam(
eventRequestId: string,
action: 'submission' | 'pr_update' | 'declined'
): Promise<boolean> {
return this.sendEmailNotification({
type: 'design_pr_notification',
eventRequestId,
additionalContext: { action }
});
}
/**
* Send officer role change notification
*/
static async notifyOfficerRoleChange(
officerId: string,
previousRole?: string,
previousType?: string,
newRole?: string,
newType?: string,
changedByUserId?: string,
isNewOfficer?: boolean
): Promise<boolean> {
return this.sendOfficerNotification({
type: 'officer_role_change',
officerId,
additionalContext: {
previousRole,
previousType,
newRole,
newType,
changedByUserId,
isNewOfficer
}
});
}
}

View file

@ -0,0 +1,112 @@
// Shared email helper functions and utilities
export function getStatusColor(status: string): string {
switch (status) {
case 'submitted': return '#ffc107';
case 'under_review': return '#17a2b8';
case 'approved': return '#28a745';
case 'rejected': return '#dc3545';
case 'in_progress': return '#6f42c1';
case 'paid': return '#20c997';
case 'declined': return '#dc3545';
default: return '#6c757d';
}
}
export function getStatusText(status: string): string {
switch (status) {
case 'submitted': return 'Submitted';
case 'under_review': return 'Under Review';
case 'approved': return 'Approved';
case 'rejected': return 'Rejected';
case 'in_progress': return 'In Progress';
case 'paid': return 'Paid';
case 'declined': return 'Declined';
default: return status.charAt(0).toUpperCase() + status.slice(1);
}
}
export function getNextStepsText(status: string): string {
switch (status) {
case 'submitted':
return 'Your reimbursement is in the queue for review. We\'ll notify you once it\'s being processed.';
case 'under_review':
return 'Our team is currently reviewing your receipts and documentation. No action needed from you.';
case 'approved':
return 'Your reimbursement has been approved! Payment processing will begin shortly.';
case 'rejected':
return 'Your reimbursement has been rejected. Please review the rejection reason above and reach out to our treasurer if you have questions or need to resubmit with corrections.';
case 'in_progress':
return 'Payment is being processed. You should receive your reimbursement within 1-2 business days.';
case 'paid':
return 'Your reimbursement has been completed! Please check your account for the payment.';
default:
return 'Check your dashboard for more details about your reimbursement status.';
}
}
export async function initializeEmailServices() {
// Import Resend and create direct PocketBase connection for server-side use
const { Resend } = await import('resend');
const PocketBase = await import('pocketbase').then(module => module.default);
// Initialize services
const pb = new PocketBase(import.meta.env.POCKETBASE_URL || 'http://127.0.0.1:8090');
const resend = new Resend(import.meta.env.RESEND_API_KEY);
if (!import.meta.env.RESEND_API_KEY) {
throw new Error('RESEND_API_KEY environment variable is required');
}
const fromEmail = import.meta.env.FROM_EMAIL || 'IEEE UCSD <noreply@ieeeucsd.org>';
const replyToEmail = import.meta.env.REPLY_TO_EMAIL || 'treasurer@ieeeucsd.org';
return { pb, resend, fromEmail, replyToEmail };
}
export function authenticatePocketBase(pb: any, authData: any) {
if (authData && authData.token && authData.model) {
console.log('🔐 Authenticating with PocketBase using provided auth data');
pb.authStore.save(authData.token, authData.model);
console.log('✅ PocketBase authentication successful');
} else {
console.warn('⚠️ No auth data provided, proceeding without authentication');
}
}
export function formatDateTime(dateString: string): string {
try {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short'
});
} catch (e) {
return dateString;
}
}
export function formatFlyerTypes(flyerTypes: string[]): string {
if (!flyerTypes || flyerTypes.length === 0) return 'None specified';
const typeMap: Record<string, string> = {
'digital_with_social': 'Digital with Social Media',
'digital_no_social': 'Digital without Social Media',
'physical_with_advertising': 'Physical with Advertising',
'physical_no_advertising': 'Physical without Advertising',
'newsletter': 'Newsletter',
'other': 'Other'
};
return flyerTypes.map(type => typeMap[type] || type).join(', ');
}
export function formatLogos(logos: string[]): string {
if (!logos || logos.length === 0) return 'None specified';
return logos.join(', ');
}

View file

@ -0,0 +1,410 @@
import { Resend } from 'resend';
import type { User, Reimbursement, Receipt } from '../../schemas/pocketbase';
// Define email template types
export type EmailTemplateType =
| 'reimbursement_status_changed'
| 'reimbursement_comment_added'
| 'reimbursement_submitted'
| 'reimbursement_approved'
| 'reimbursement_rejected'
| 'reimbursement_paid';
// Email template data interfaces
export interface StatusChangeEmailData {
user: User;
reimbursement: Reimbursement;
previousStatus: string;
newStatus: string;
changedBy?: string;
comment?: string;
}
export interface CommentEmailData {
user: User;
reimbursement: Reimbursement;
comment: string;
commentBy: string;
isPrivate: boolean;
}
export interface ReimbursementEmailData {
user: User;
reimbursement: Reimbursement;
receipts?: Receipt[];
additionalData?: Record<string, any>;
}
export class EmailService {
private resend: Resend;
private fromEmail: string;
private replyToEmail: string;
constructor() {
// Initialize Resend with API key from environment
// Use import.meta.env as used throughout the Astro project
const apiKey = import.meta.env.RESEND_API_KEY;
if (!apiKey) {
throw new Error('RESEND_API_KEY environment variable is required');
}
this.resend = new Resend(apiKey);
this.fromEmail = import.meta.env.FROM_EMAIL || 'IEEE UCSD <noreply@transactional.ieeeatucsd.org>';
this.replyToEmail = import.meta.env.REPLY_TO_EMAIL || 'ieee@ucsd.edu';
}
private static instance: EmailService | null = null;
public static getInstance(): EmailService {
if (!EmailService.instance) {
EmailService.instance = new EmailService();
}
return EmailService.instance;
}
/**
* Send reimbursement status change notification
*/
async sendStatusChangeEmail(data: StatusChangeEmailData): Promise<boolean> {
try {
const { user, reimbursement, previousStatus, newStatus, changedBy, comment } = data;
const subject = `Reimbursement Status Updated: ${reimbursement.title}`;
const statusColor = this.getStatusColor(newStatus);
const statusText = this.getStatusText(newStatus);
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Update</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">Status Update</h2>
<p>Hello ${user.name},</p>
<p>Your reimbursement request "<strong>${reimbursement.title}</strong>" has been updated.</p>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${statusColor}; margin: 20px 0;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<span style="font-weight: bold; color: #666;">Status Change:</span>
<span style="background: ${statusColor}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500;">${statusText}</span>
</div>
${previousStatus !== newStatus ? `
<div style="color: #666; font-size: 14px;">
Changed from: <span style="text-decoration: line-through;">${this.getStatusText(previousStatus)}</span> <strong>${statusText}</strong>
</div>
` : ''}
${changedBy ? `
<div style="color: #666; font-size: 14px; margin-top: 10px;">
Updated by: ${changedBy}
</div>
` : ''}
</div>
${comment ? `
<div style="background: #e8f4fd; padding: 15px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #3498db;">
<h4 style="margin: 0 0 10px 0; color: #2980b9;">Additional Note:</h4>
<p style="margin: 0; font-style: italic;">${comment}</p>
</div>
` : ''}
<div style="margin: 25px 0;">
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Amount:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Payment Method:</td>
<td style="padding: 8px 0;">${reimbursement.payment_method}</td>
</tr>
</table>
</div>
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;">
<p style="margin: 0; font-size: 14px;"><strong>Next Steps:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
${this.getNextStepsText(newStatus)}
</p>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
<p>If you have any questions, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
</div>
</body>
</html>
`;
const result = await this.resend.emails.send({
from: this.fromEmail,
to: [user.email],
replyTo: this.replyToEmail,
subject,
html,
});
console.log('Status change email sent successfully:', result);
return true;
} catch (error) {
console.error('Failed to send status change email:', error);
return false;
}
}
/**
* Send comment notification email
*/
async sendCommentEmail(data: CommentEmailData): Promise<boolean> {
try {
const { user, reimbursement, comment, commentBy, isPrivate } = data;
// Don't send email for private comments unless the user is the recipient
if (isPrivate) {
return true; // Silently skip private comments for now
}
const subject = `New Comment on Reimbursement: ${reimbursement.title}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Comment</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">New Comment Added</h2>
<p>Hello ${user.name},</p>
<p>A new comment has been added to your reimbursement request "<strong>${reimbursement.title}</strong>".</p>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #3498db; margin: 20px 0;">
<div style="margin-bottom: 15px;">
<span style="font-weight: bold; color: #2980b9;">Comment by:</span> ${commentBy}
</div>
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px;">
<p style="margin: 0; font-style: italic;">${comment}</p>
</div>
</div>
<div style="margin: 25px 0;">
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Status:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">
<span style="background: ${this.getStatusColor(reimbursement.status)}; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
${this.getStatusText(reimbursement.status)}
</span>
</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Date of Purchase:</td>
<td style="padding: 8px 0;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
</tr>
</table>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
<p>If you have any questions, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
</div>
</body>
</html>
`;
const result = await this.resend.emails.send({
from: this.fromEmail,
to: [user.email],
replyTo: this.replyToEmail,
subject,
html,
});
console.log('Comment email sent successfully:', result);
return true;
} catch (error) {
console.error('Failed to send comment email:', error);
return false;
}
}
/**
* Send reimbursement submission confirmation
*/
async sendSubmissionConfirmation(data: ReimbursementEmailData): Promise<boolean> {
try {
const { user, reimbursement } = data;
const subject = `Reimbursement Submitted: ${reimbursement.title}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;"> Reimbursement Submitted Successfully</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">Submission Confirmed</h2>
<p>Hello ${user.name},</p>
<p>Your reimbursement request has been successfully submitted and is now under review.</p>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
<h3 style="margin-top: 0; color: #155724;">Reimbursement Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Title:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.title}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Payment Method:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.payment_method}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Status:</td>
<td style="padding: 8px 0;">
<span style="background: #ffc107; color: #212529; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
Submitted
</span>
</td>
</tr>
</table>
</div>
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
<h4 style="margin: 0 0 10px 0; color: #155724;">What happens next?</h4>
<ul style="margin: 0; padding-left: 20px; color: #155724;">
<li>Your receipts will be reviewed by our team</li>
<li>You'll receive email updates as the status changes</li>
<li>Once approved, payment will be processed</li>
<li>Typical processing time is 1-2 weeks</li>
</ul>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
<p>If you have any questions, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
</div>
</body>
</html>
`;
const result = await this.resend.emails.send({
from: this.fromEmail,
to: [user.email],
replyTo: this.replyToEmail,
subject,
html,
});
console.log('Submission confirmation email sent successfully:', result);
return true;
} catch (error) {
console.error('Failed to send submission confirmation email:', error);
return false;
}
}
/**
* Get status color for styling
*/
private getStatusColor(status: string): string {
switch (status) {
case 'submitted': return '#ffc107';
case 'under_review': return '#17a2b8';
case 'approved': return '#28a745';
case 'rejected': return '#dc3545';
case 'in_progress': return '#6f42c1';
case 'paid': return '#20c997';
default: return '#6c757d';
}
}
/**
* Get human-readable status text
*/
private getStatusText(status: string): string {
switch (status) {
case 'submitted': return 'Submitted';
case 'under_review': return 'Under Review';
case 'approved': return 'Approved';
case 'rejected': return 'Rejected';
case 'in_progress': return 'In Progress';
case 'paid': return 'Paid';
default: return status.charAt(0).toUpperCase() + status.slice(1);
}
}
/**
* Get next steps text based on status
*/
private getNextStepsText(status: string): string {
switch (status) {
case 'submitted':
return 'Your reimbursement is in the queue for review. We\'ll notify you once it\'s being processed.';
case 'under_review':
return 'Our team is currently reviewing your receipts and documentation. No action needed from you.';
case 'approved':
return 'Your reimbursement has been approved! Payment processing will begin shortly.';
case 'rejected':
return 'Your reimbursement has been rejected. Please review the comments and reach out if you have questions.';
case 'in_progress':
return 'Payment is being processed. You should receive your reimbursement within 1-2 business days.';
case 'paid':
return 'Your reimbursement has been completed! Please check your account for the payment.';
default:
return 'Check your dashboard for more details about your reimbursement status.';
}
}
}

View file

@ -0,0 +1,429 @@
import { getStatusColor, getStatusText, formatDateTime, formatFlyerTypes, formatLogos } from './EmailHelpers';
export async function sendEventRequestSubmissionEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
try {
console.log('🎪 Starting event request submission email process...');
// Get event request details
const eventRequest = await pb.collection('event_request').getOne(data.eventRequestId);
const user = await pb.collection('users').getOne(eventRequest.requested_user);
if (!user) {
console.error('❌ User not found:', eventRequest.requested_user);
return false;
}
const coordinatorsEmail = 'coordinators@ieeeatucsd.org';
const subject = `New Event Request Submitted: ${eventRequest.name}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">🎪 New Event Request Submitted</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">Event Request Details</h2>
<p>Hello Coordinators,</p>
<p>A new event request has been submitted by <strong>${user.name}</strong> and requires your review.</p>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
<h3 style="margin-top: 0; color: #155724;">Basic Information</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Event Name:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.name}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Location:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.location}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Start Date & Time:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${formatDateTime(eventRequest.start_date_time)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">End Date & Time:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${formatDateTime(eventRequest.end_date_time)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Expected Attendance:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.expected_attendance || 'Not specified'}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Submitted By:</td>
<td style="padding: 8px 0;">${user.name} (${user.email})</td>
</tr>
</table>
</div>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #17a2b8; margin: 20px 0;">
<h3 style="margin-top: 0; color: #0c5460;">Event Description</h3>
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px;">
<p style="margin: 0; white-space: pre-wrap;">${eventRequest.event_description || 'No description provided'}</p>
</div>
</div>
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
<h4 style="margin: 0 0 10px 0; color: #155724;">Next Steps</h4>
<ul style="margin: 0; padding-left: 20px; color: #155724;">
<li>Review the event request details in the dashboard</li>
<li>Coordinate with the submitter if clarification is needed</li>
<li>Assign tasks to appropriate team members</li>
<li>Update the event request status once processed</li>
</ul>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Event Management System.</p>
<p>Event Request ID: ${eventRequest.id}</p>
<p>If you have any questions, please contact the submitter at <a href="mailto:${user.email}" style="color: #667eea;">${user.email}</a></p>
</div>
</body>
</html>
`;
const result = await resend.emails.send({
from: fromEmail,
to: [coordinatorsEmail],
replyTo: user.email,
subject,
html,
});
console.log('✅ Event request notification email sent successfully!');
return true;
} catch (error) {
console.error('❌ Failed to send event request notification email:', error);
return false;
}
}
export async function sendEventRequestStatusChangeEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
try {
console.log('🎯 Starting event request status change email process...');
// Get event request details
const eventRequest = await pb.collection('event_request').getOne(data.eventRequestId);
const user = await pb.collection('users').getOne(eventRequest.requested_user);
if (!user) {
console.error('❌ User not found:', eventRequest.requested_user);
return false;
}
const coordinatorsEmail = 'coordinators@ieeeatucsd.org';
const userSubject = `Your Event Request Status Updated: ${eventRequest.name}`;
const userHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${userSubject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Event Request Update</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">Status Update</h2>
<p>Hello ${user.name},</p>
<p>Your event request "<strong>${eventRequest.name}</strong>" has been updated.</p>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${getStatusColor(data.newStatus)}; margin: 20px 0;">
<div style="margin-bottom: 15px;">
<span style="font-weight: bold; color: #666;">Status:</span>
<span style="background: ${getStatusColor(data.newStatus)}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500; margin-left: 10px;">${getStatusText(data.newStatus)}</span>
</div>
${data.previousStatus && data.previousStatus !== data.newStatus ? `
<div style="color: #666; font-size: 14px;">
Changed from: <span style="text-decoration: line-through;">${getStatusText(data.previousStatus)}</span> <strong>${getStatusText(data.newStatus)}</strong>
</div>
` : ''}
${data.newStatus === 'declined' && data.declinedReason ? `
<div style="background: #f8d7da; padding: 15px; border-radius: 8px; border-left: 4px solid #dc3545; margin: 15px 0;">
<p style="margin: 0; color: #721c24;"><strong>Decline Reason:</strong></p>
<p style="margin: 5px 0 0 0; color: #721c24;">${data.declinedReason}</p>
</div>
` : ''}
</div>
<div style="margin: 25px 0;">
<h3 style="color: #2c3e50; margin-bottom: 15px;">Your Event Request Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Event Name:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.name}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Status:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${getStatusText(data.newStatus)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Location:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.location}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Event Date:</td>
<td style="padding: 8px 0;">${formatDateTime(eventRequest.start_date_time)}</td>
</tr>
</table>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Event Management System.</p>
<p>Event Request ID: ${eventRequest.id}</p>
<p>If you have any questions, please contact us at <a href="mailto:coordinators@ieeeatucsd.org" style="color: #667eea;">coordinators@ieeeatucsd.org</a></p>
</div>
</body>
</html>
`;
// Send email to user
await resend.emails.send({
from: fromEmail,
to: [user.email],
replyTo: replyToEmail,
subject: userSubject,
html: userHtml,
});
// Send email to coordinators
const coordinatorSubject = `Event Request Status Updated: ${eventRequest.name}`;
await resend.emails.send({
from: fromEmail,
to: [coordinatorsEmail],
replyTo: user.email,
subject: coordinatorSubject,
html: userHtml.replace(user.name, 'Coordinators').replace('Your event request', `Event request by ${user.name}`),
});
console.log('✅ Event request status change emails sent successfully!');
return true;
} catch (error) {
console.error('❌ Failed to send event request status change email:', error);
return false;
}
}
export async function sendPRCompletedEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
try {
console.log('🎨 Starting PR completed email process...');
// Get event request details
const eventRequest = await pb.collection('event_request').getOne(data.eventRequestId);
const user = await pb.collection('users').getOne(eventRequest.requested_user);
if (!user || !user.email) {
console.error('❌ User not found or no email:', eventRequest.requested_user);
return false;
}
const subject = `PR Materials Completed for Your Event: ${eventRequest.name}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">🎨 PR Materials Completed!</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">Great News!</h2>
<p>Hello ${user.name},</p>
<p>The PR materials for your event "<strong>${eventRequest.name}</strong>" have been completed by our PR team!</p>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
<div style="margin-bottom: 15px;">
<span style="background: #28a745; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500;">
PR Materials Completed
</span>
</div>
<h3 style="margin-top: 0; color: #155724;">Event Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Event Name:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.name}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Location:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.location}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Event Date:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${formatDateTime(eventRequest.start_date_time)}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Flyers Needed:</td>
<td style="padding: 8px 0;">${eventRequest.flyers_needed ? 'Yes' : 'No'}</td>
</tr>
</table>
</div>
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;">
<h4 style="margin: 0 0 10px 0; color: #856404;">📞 Next Steps</h4>
<p style="margin: 0; color: #856404;">
<strong>Important:</strong> Please reach out to the Internal team to coordinate any remaining logistics for your event.
</p>
<p style="margin: 10px 0 0 0; color: #856404;">
Contact: <strong>internal@ieeeatucsd.org</strong>
</p>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Event Management System.</p>
<p>Event Request ID: ${eventRequest.id}</p>
<p>If you have any questions about your PR materials, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
</div>
</body>
</html>
`;
await resend.emails.send({
from: fromEmail,
to: [user.email],
replyTo: replyToEmail,
subject,
html,
});
console.log('✅ PR completed email sent successfully!');
return true;
} catch (error) {
console.error('❌ Failed to send PR completed email:', error);
return false;
}
}
export async function sendDesignPRNotificationEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
try {
console.log('🎨 Starting design PR notification email process...');
// Get event request details
const eventRequest = await pb.collection('event_request').getOne(data.eventRequestId);
const user = await pb.collection('users').getOne(eventRequest.requested_user);
if (!user) {
console.error('❌ User not found:', eventRequest.requested_user);
return false;
}
const designEmail = 'design@ieeeatucsd.org';
let subject = '';
let actionMessage = '';
switch (data.action) {
case 'submission':
subject = `New Event Request with PR Materials: ${eventRequest.name}`;
actionMessage = 'A new event request has been submitted that requires PR materials.';
break;
case 'pr_update':
subject = `PR Materials Updated: ${eventRequest.name}`;
actionMessage = 'The PR materials for this event request have been updated.';
break;
case 'declined':
subject = `Event Request Declined - PR Work Cancelled: ${eventRequest.name}`;
actionMessage = 'This event request has been declined. Please ignore any pending PR work for this event.';
break;
default:
subject = `Event Request PR Notification: ${eventRequest.name}`;
actionMessage = 'There has been an update to an event request requiring PR materials.';
}
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">🎨 IEEE UCSD Design Team Notification</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">PR Materials ${data.action === 'declined' ? 'Cancelled' : 'Required'}</h2>
<p>Hello Design Team,</p>
<p>${actionMessage}</p>
<div style="margin: 25px 0;">
<h3 style="color: #2c3e50; margin-bottom: 15px;">Event Request Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Event Name:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.name}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Action:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${data.action.charAt(0).toUpperCase() + data.action.slice(1)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Submitted By:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${user.name} (${user.email})</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Event Description:</td>
<td style="padding: 8px 0;">${eventRequest.event_description}</td>
</tr>
</table>
</div>
${data.action !== 'declined' ? `
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
<p style="margin: 0; color: #155724;"><strong>Next Steps:</strong> Please coordinate with the internal team for PR material creation and timeline.</p>
</div>
` : `
<div style="background: #f8d7da; padding: 15px; border-radius: 8px; border-left: 4px solid #dc3545; margin: 20px 0;">
<p style="margin: 0; color: #721c24;"><strong>Note:</strong> This event has been declined. No further PR work is needed.</p>
</div>
`}
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Event Management System.</p>
<p>Event Request ID: ${eventRequest.id}</p>
<p>If you have any questions, please contact <a href="mailto:internal@ieeeatucsd.org" style="color: #667eea;">internal@ieeeatucsd.org</a></p>
</div>
</body>
</html>
`;
await resend.emails.send({
from: fromEmail,
to: [designEmail],
replyTo: replyToEmail,
subject,
html,
});
console.log('✅ Design PR notification email sent successfully!');
return true;
} catch (error) {
console.error('❌ Failed to send design PR notification email:', error);
return false;
}
}

View file

@ -0,0 +1,296 @@
import { Resend } from 'resend';
import type { User, Officer } from '../../schemas/pocketbase/schema';
import { OfficerTypes } from '../../schemas/pocketbase';
// Email template data interfaces
export interface OfficerRoleChangeEmailData {
user: User;
officer: Officer;
previousRole?: string;
previousType?: string;
newRole: string;
newType: string;
changedBy?: string;
isNewOfficer?: boolean; // If this is a new officer appointment
}
export class OfficerEmailNotifications {
private resend: Resend;
private fromEmail: string;
private replyToEmail: string;
constructor() {
// Initialize Resend with API key from environment
const apiKey = import.meta.env.RESEND_API_KEY;
if (!apiKey) {
throw new Error('RESEND_API_KEY environment variable is required');
}
this.resend = new Resend(apiKey);
this.fromEmail = import.meta.env.FROM_EMAIL || 'IEEE UCSD <noreply@transactional.ieeeatucsd.org>';
this.replyToEmail = import.meta.env.REPLY_TO_EMAIL || 'ieee@ucsd.edu';
}
private static instance: OfficerEmailNotifications | null = null;
public static getInstance(): OfficerEmailNotifications {
if (!OfficerEmailNotifications.instance) {
OfficerEmailNotifications.instance = new OfficerEmailNotifications();
}
return OfficerEmailNotifications.instance;
}
/**
* Send officer role change notification email
*/
async sendRoleChangeNotification(data: OfficerRoleChangeEmailData): Promise<boolean> {
try {
const { user, officer, previousRole, previousType, newRole, newType, changedBy, isNewOfficer } = data;
const subject = isNewOfficer
? `Welcome to IEEE UCSD Leadership - ${newRole}`
: `Your IEEE UCSD Officer Role has been Updated`;
const typeColor = this.getOfficerTypeColor(newType);
const typeText = this.getOfficerTypeDisplayName(newType);
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Officer Update</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">
${isNewOfficer ? 'Welcome to the Team!' : 'Role Update'}
</h2>
<p>Hello ${user.name},</p>
${isNewOfficer ? `
<p>Congratulations! You have been appointed as an officer for IEEE UCSD. We're excited to have you join our leadership team!</p>
` : `
<p>Your officer role has been updated in the IEEE UCSD system.</p>
`}
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${typeColor}; margin: 20px 0;">
<div style="margin-bottom: 15px;">
<h3 style="margin: 0 0 10px 0; color: #2c3e50;">Your Current Role</h3>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span style="font-weight: bold; font-size: 18px; color: #2c3e50;">${newRole}</span>
<span style="background: ${typeColor}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500;">${typeText}</span>
</div>
</div>
${!isNewOfficer && (previousRole || previousType) ? `
<div style="color: #666; font-size: 14px; padding: 10px 0; border-top: 1px solid #eee;">
<strong>Previous:</strong> ${previousRole || 'Unknown Role'} (${this.getOfficerTypeDisplayName(previousType || '')})
</div>
` : ''}
${changedBy ? `
<div style="color: #666; font-size: 14px; margin-top: 10px;">
${isNewOfficer ? 'Appointed' : 'Updated'} by: ${changedBy}
</div>
` : ''}
</div>
<div style="margin: 25px 0;">
<h3 style="color: #2c3e50; margin-bottom: 15px;">Officer Information</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Name:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${user.name}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Email:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${user.email}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Role:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${newRole}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Officer Type:</td>
<td style="padding: 8px 0;">${typeText}</td>
</tr>
</table>
</div>
${this.getOfficerTypeDescription(newType)}
<div style="background: #e8f4fd; padding: 15px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #3498db;">
<h4 style="margin: 0 0 10px 0; color: #2980b9;">Next Steps:</h4>
<ul style="margin: 0; padding-left: 20px;">
<li>Check your access to the officer dashboard</li>
<li>Familiarize yourself with your new responsibilities</li>
<li>Reach out to other officers if you have questions</li>
${isNewOfficer ? '<li>Attend the next officer meeting to get up to speed</li>' : ''}
</ul>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Officer Management System.</p>
<p>If you have any questions about your role, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
</div>
</body>
</html>
`;
const result = await this.resend.emails.send({
from: this.fromEmail,
to: [user.email],
replyTo: this.replyToEmail,
subject,
html,
});
console.log('Officer role change email sent successfully:', result);
return true;
} catch (error) {
console.error('Failed to send officer role change email:', error);
return false;
}
}
/**
* Get color for officer type badge
*/
private getOfficerTypeColor(type: string): string {
switch (type) {
case OfficerTypes.ADMINISTRATOR:
return '#dc3545'; // Red for admin
case OfficerTypes.EXECUTIVE:
return '#6f42c1'; // Purple for executive
case OfficerTypes.GENERAL:
return '#007bff'; // Blue for general
case OfficerTypes.HONORARY:
return '#fd7e14'; // Orange for honorary
case OfficerTypes.PAST:
return '#6c757d'; // Gray for past
default:
return '#28a745'; // Green as default
}
}
/**
* Get display name for officer type
*/
private getOfficerTypeDisplayName(type: string): string {
switch (type) {
case OfficerTypes.ADMINISTRATOR:
return 'Administrator';
case OfficerTypes.EXECUTIVE:
return 'Executive Officer';
case OfficerTypes.GENERAL:
return 'General Officer';
case OfficerTypes.HONORARY:
return 'Honorary Officer';
case OfficerTypes.PAST:
return 'Past Officer';
default:
return 'Officer';
}
}
/**
* Get description for officer type
*/
private getOfficerTypeDescription(type: string): string {
const baseStyle = "background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;";
switch (type) {
case OfficerTypes.ADMINISTRATOR:
return `
<div style="${baseStyle}">
<p style="margin: 0; font-size: 14px;"><strong>Administrator Role:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
As an administrator, you have full access to manage officers, events, and system settings. You can add/remove other officers and access all administrative features.
</p>
</div>
`;
case OfficerTypes.EXECUTIVE:
return `
<div style="${baseStyle}">
<p style="margin: 0; font-size: 14px;"><strong>Executive Officer Role:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
As an executive officer, you have leadership responsibilities and access to advanced features in the officer dashboard. You can manage events and participate in key decision-making.
</p>
</div>
`;
case OfficerTypes.GENERAL:
return `
<div style="${baseStyle}">
<p style="margin: 0; font-size: 14px;"><strong>General Officer Role:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
As a general officer, you have access to the officer dashboard and can help with event management, member engagement, and other organizational activities.
</p>
</div>
`;
case OfficerTypes.HONORARY:
return `
<div style="${baseStyle}">
<p style="margin: 0; font-size: 14px;"><strong>Honorary Officer Role:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
As an honorary officer, you are recognized for your contributions to IEEE UCSD. You have access to officer resources and are part of our leadership community.
</p>
</div>
`;
case OfficerTypes.PAST:
return `
<div style="${baseStyle}">
<p style="margin: 0; font-size: 14px;"><strong>Past Officer Status:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
Thank you for your service to IEEE UCSD! As a past officer, you maintain access to alumni resources and remain part of our leadership community.
</p>
</div>
`;
default:
return `
<div style="${baseStyle}">
<p style="margin: 0; font-size: 14px;"><strong>Officer Role:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
Welcome to the IEEE UCSD officer team! You now have access to officer resources and can contribute to our organization's activities.
</p>
</div>
`;
}
}
/**
* Batch notify multiple officers (for bulk operations)
*/
async notifyBulkRoleChanges(
notifications: OfficerRoleChangeEmailData[]
): Promise<{ successful: number; failed: number }> {
let successful = 0;
let failed = 0;
for (const notification of notifications) {
try {
const result = await this.sendRoleChangeNotification(notification);
if (result) {
successful++;
} else {
failed++;
}
// Add a small delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
console.error('Failed to send bulk notification:', error);
failed++;
}
}
return { successful, failed };
}
}

203
src/scripts/email/README.md Normal file
View file

@ -0,0 +1,203 @@
# Email Notification System
This directory contains the email notification system for the IEEE UCSD reimbursement portal and event management system using Resend.
## Setup
### Environment Variables
Add the following environment variables to your `.env` file:
```bash
# PocketBase Configuration
POCKETBASE_URL=https://pocketbase.ieeeucsd.org
# Resend API Configuration
RESEND_API_KEY=your_resend_api_key_here
# Email Configuration
FROM_EMAIL="IEEE UCSD <noreply@ieeeucsd.org>"
REPLY_TO_EMAIL="treasurer@ieeeucsd.org"
```
**Note**: This project uses Astro's standard environment variable pattern with `import.meta.env.VARIABLE_NAME`. No PUBLIC_ prefix is needed as these are used in API routes and server-side code.
### Getting a Resend API Key
1. Sign up for a [Resend account](https://resend.com)
2. Go to your dashboard and create a new API key
3. Add the API key to your environment variables
## Features
### Automatic Email Notifications
The system automatically sends emails for the following events:
#### Reimbursement System
1. **Reimbursement Submitted** - Confirmation email when a user submits a new reimbursement request
2. **Status Changes** - Notification when reimbursement status is updated (submitted, under review, approved, rejected, in progress, paid)
3. **Comments Added** - Notification when someone adds a public comment to a reimbursement
4. **Rejections with Reasons** - Detailed rejection notification including the specific reason for rejection
#### Event Management System
1. **Event Request Submitted** - Notification to coordinators@ieeeatucsd.org when a new event request is submitted
Note: Private comments are not sent via email to maintain privacy.
### Email Templates
All emails include:
- Professional IEEE UCSD branding
- Responsive design for mobile and desktop
- Clear status indicators with color coding
- Detailed information summary
- Next steps information
- Contact information for support
## Usage
### In React Components (Client-side)
#### Reimbursement Notifications
```typescript
import { EmailClient } from '../../../scripts/email/EmailClient';
// Send status change notification
await EmailClient.notifyStatusChange(reimbursementId, newStatus, previousStatus, userId);
// Send comment notification
await EmailClient.notifyComment(reimbursementId, comment, commentByUserId, isPrivate);
// Send submission confirmation
await EmailClient.notifySubmission(reimbursementId);
// Send rejection with reason (recommended for rejections)
await EmailClient.notifyRejection(reimbursementId, rejectionReason, previousStatus, userId);
// Send test email
await EmailClient.sendTestEmail('your-email@example.com');
// Alternative: Send rejection via notifyStatusChange with additionalContext
await EmailClient.notifyStatusChange(
reimbursementId,
'rejected',
previousStatus,
userId,
{ rejectionReason: 'Missing receipt for coffee purchase. Please resubmit with proper documentation.' }
);
```
#### Event Request Notifications
```typescript
import { EmailClient } from '../../../scripts/email/EmailClient';
// Send event request submission notification to coordinators
await EmailClient.notifyEventRequestSubmission(eventRequestId);
```
### API Route (Server-side)
The API route at `/api/email/send-reimbursement-notification` accepts POST requests with the following structure:
#### Reimbursement Notifications
```json
{
"type": "status_change" | "comment" | "submission" | "test",
"reimbursementId": "string",
"newStatus": "string", // for status_change
"previousStatus": "string", // for status_change
"changedByUserId": "string", // for status_change
"comment": "string", // for comment
"commentByUserId": "string", // for comment
"isPrivate": boolean, // for comment
"additionalContext": {}, // for additional data
"authData": { // Authentication data for PocketBase access
"token": "string",
"model": {}
}
}
```
#### Event Request Notifications
```json
{
"type": "event_request_submission",
"eventRequestId": "string",
"authData": { // Authentication data for PocketBase access
"token": "string",
"model": {}
}
}
```
## Architecture
The email system uses a client-server architecture for security and authentication:
- `EmailService.ts` - Core email service using Resend (server-side only)
- `ReimbursementEmailNotifications.ts` - High-level notification service (server-side only)
- `EmailClient.ts` - Client-side helper that calls the API with authentication
- `/api/email/send-reimbursement-notification.ts` - API route that handles server-side email sending with PocketBase authentication
### Authentication Flow
1. **Client-side**: `EmailClient` gets the current user's authentication token and model from the `Authentication` service
2. **API Request**: The auth data is sent to the server-side API route
3. **Server-side**: The API route authenticates with PocketBase using the provided auth data
4. **Database Access**: The authenticated PocketBase connection can access protected collections
5. **Email Sending**: Emails are sent using the Resend service with proper user data
This ensures that:
- API keys are never exposed to the client-side code
- Only authenticated users can trigger email notifications
- The server can access protected PocketBase collections
- Email operations respect user permissions and data security
## Email Recipients
- **Reimbursement notifications**: Sent to the user who submitted the reimbursement
- **Event request notifications**: Sent to coordinators@ieeeatucsd.org
- **Test emails**: Sent to the specified email address
## Error Handling
Email failures are logged but do not prevent the main operations from completing. This ensures that reimbursement processing and event request submissions continue even if email delivery fails.
## Security
- API keys are loaded from environment variables server-side only
- Authentication tokens are passed securely from client to server
- Email addresses are validated before sending
- Private comments are not sent via email (configurable)
- All emails include appropriate contact information
- PocketBase collection access respects authentication and permissions
## Event Request Email Notifications
### Event Request Submission
When a new event request is submitted, an email is automatically sent to the coordinators team.
```typescript
await EmailClient.notifyEventRequestSubmission(eventRequestId);
```
### Event Request Status Change
When an event request status is changed, an email is sent to coordinators.
```typescript
await EmailClient.notifyEventRequestStatusChange(eventRequestId, previousStatus, newStatus, changedByUserId);
```
### PR Completion Notification
When PR materials are completed for an event request, an email is sent to the submitter notifying them to contact the internal team.
```typescript
await EmailClient.notifyPRCompleted(eventRequestId);
```
This email includes:
- Confirmation that PR materials are completed
- Event details and information
- Instructions to contact the internal team for next steps
- Contact information for internal@ieeeucsd.org

View file

@ -0,0 +1,310 @@
import { EmailService, type StatusChangeEmailData, type CommentEmailData, type ReimbursementEmailData } from './EmailService';
import { Get } from '../pocketbase/Get';
import type { User, Reimbursement, Receipt } from '../../schemas/pocketbase';
export class ReimbursementEmailNotifications {
private emailService: EmailService;
private get: Get;
constructor() {
this.emailService = EmailService.getInstance();
this.get = Get.getInstance();
}
private static instance: ReimbursementEmailNotifications | null = null;
public static getInstance(): ReimbursementEmailNotifications {
if (!ReimbursementEmailNotifications.instance) {
ReimbursementEmailNotifications.instance = new ReimbursementEmailNotifications();
}
return ReimbursementEmailNotifications.instance;
}
/**
* Send notification when reimbursement status changes
*/
async notifyStatusChange(
reimbursementId: string,
previousStatus: string,
newStatus: string,
changedByUserId?: string,
comment?: string
): Promise<boolean> {
try {
// Get reimbursement details
const reimbursement = await this.get.getOne<Reimbursement>('reimbursement', reimbursementId);
if (!reimbursement) {
console.error('Reimbursement not found:', reimbursementId);
return false;
}
// Get submitter user details
const user = await this.get.getOne<User>('users', reimbursement.submitted_by);
if (!user || !user.email) {
console.error('User not found or no email:', reimbursement.submitted_by);
return false;
}
// Get changed by user name if provided
let changedByName = 'System';
if (changedByUserId) {
try {
const changedByUser = await this.get.getOne<User>('users', changedByUserId);
changedByName = changedByUser?.name || 'Unknown User';
} catch (error) {
console.warn('Could not get changed by user name:', error);
}
}
const emailData: StatusChangeEmailData = {
user,
reimbursement,
previousStatus,
newStatus,
changedBy: changedByName,
comment
};
return await this.emailService.sendStatusChangeEmail(emailData);
} catch (error) {
console.error('Failed to send status change notification:', error);
return false;
}
}
/**
* Send notification when a comment is added to a reimbursement
*/
async notifyComment(
reimbursementId: string,
comment: string,
commentByUserId: string,
isPrivate: boolean = false
): Promise<boolean> {
try {
// Don't send emails for private comments (for now)
if (isPrivate) {
return true;
}
// Get reimbursement details
const reimbursement = await this.get.getOne<Reimbursement>('reimbursement', reimbursementId);
if (!reimbursement) {
console.error('Reimbursement not found:', reimbursementId);
return false;
}
// Get submitter user details
const user = await this.get.getOne<User>('users', reimbursement.submitted_by);
if (!user || !user.email) {
console.error('User not found or no email:', reimbursement.submitted_by);
return false;
}
// Don't send email if the commenter is the same as the submitter
if (commentByUserId === reimbursement.submitted_by) {
return true;
}
// Get commenter user name
let commentByName = 'Unknown User';
try {
const commentByUser = await this.get.getOne<User>('users', commentByUserId);
commentByName = commentByUser?.name || 'Unknown User';
} catch (error) {
console.warn('Could not get commenter user name:', error);
}
const emailData: CommentEmailData = {
user,
reimbursement,
comment,
commentBy: commentByName,
isPrivate
};
return await this.emailService.sendCommentEmail(emailData);
} catch (error) {
console.error('Failed to send comment notification:', error);
return false;
}
}
/**
* Send submission confirmation email
*/
async notifySubmission(reimbursementId: string): Promise<boolean> {
try {
// Get reimbursement details
const reimbursement = await this.get.getOne<Reimbursement>('reimbursement', reimbursementId);
if (!reimbursement) {
console.error('Reimbursement not found:', reimbursementId);
return false;
}
// Get submitter user details
const user = await this.get.getOne<User>('users', reimbursement.submitted_by);
if (!user || !user.email) {
console.error('User not found or no email:', reimbursement.submitted_by);
return false;
}
// Get receipt details if needed
let receipts: Receipt[] = [];
if (reimbursement.receipts && reimbursement.receipts.length > 0) {
try {
receipts = await Promise.all(
reimbursement.receipts.map(id => this.get.getOne<Receipt>('receipts', id))
);
} catch (error) {
console.warn('Could not load receipt details:', error);
}
}
const emailData: ReimbursementEmailData = {
user,
reimbursement,
receipts
};
return await this.emailService.sendSubmissionConfirmation(emailData);
} catch (error) {
console.error('Failed to send submission confirmation:', error);
return false;
}
}
/**
* Send specific status-based notifications with custom logic
*/
async notifyByStatus(
reimbursementId: string,
status: string,
previousStatus?: string,
triggeredByUserId?: string,
additionalContext?: Record<string, any>
): Promise<boolean> {
try {
switch (status) {
case 'submitted':
return await this.notifySubmission(reimbursementId);
case 'approved':
return await this.notifyStatusChange(
reimbursementId,
previousStatus || 'under_review',
status,
triggeredByUserId,
'Your reimbursement has been approved and will be processed for payment.'
);
case 'rejected':
const rejectionReason = additionalContext?.rejectionReason;
return await this.notifyStatusChange(
reimbursementId,
previousStatus || 'under_review',
status,
triggeredByUserId,
rejectionReason ? `Rejection reason: ${rejectionReason}` : undefined
);
case 'paid':
return await this.notifyStatusChange(
reimbursementId,
previousStatus || 'in_progress',
status,
triggeredByUserId,
'Your reimbursement has been completed. Please check your account for the payment.'
);
case 'under_review':
case 'in_progress':
return await this.notifyStatusChange(
reimbursementId,
previousStatus || 'submitted',
status,
triggeredByUserId
);
default:
console.log(`No specific notification handler for status: ${status}`);
return true;
}
} catch (error) {
console.error(`Failed to send notification for status ${status}:`, error);
return false;
}
}
/**
* Batch notify multiple users (for administrative notifications)
*/
async notifyAdmins(
subject: string,
message: string,
reimbursementId?: string
): Promise<boolean> {
try {
// This could be enhanced to get admin user emails from the officers table
// For now, we'll just log this functionality
console.log('Admin notification requested:', { subject, message, reimbursementId });
// TODO: Implement admin notification logic
// - Get list of admin users from officers table
// - Send email to all admins
return true;
} catch (error) {
console.error('Failed to send admin notification:', error);
return false;
}
}
/**
* Test email functionality (useful for development)
*/
async testEmail(userEmail: string): Promise<boolean> {
try {
// Create a test user object
const testUser: User = {
id: 'test-user',
created: new Date().toISOString(),
updated: new Date().toISOString(),
email: userEmail,
emailVisibility: true,
verified: true,
name: 'Test User'
};
// Create a test reimbursement object
const testReimbursement: Reimbursement = {
id: 'test-reimbursement',
created: new Date().toISOString(),
updated: new Date().toISOString(),
title: 'Test Reimbursement',
total_amount: 99.99,
date_of_purchase: new Date().toISOString(),
payment_method: 'Personal Credit Card',
status: 'submitted',
submitted_by: 'test-user',
additional_info: 'This is a test reimbursement for email functionality.',
receipts: [],
department: 'events'
};
const emailData: StatusChangeEmailData = {
user: testUser,
reimbursement: testReimbursement,
previousStatus: 'submitted',
newStatus: 'approved',
changedBy: 'Test Admin',
comment: 'This is a test email notification.'
};
return await this.emailService.sendStatusChangeEmail(emailData);
} catch (error) {
console.error('Failed to send test email:', error);
return false;
}
}
}

View file

@ -37,6 +37,38 @@ export default {
"radial-gradient(circle at 0% 0%, var(--tw-gradient-stops))", "radial-gradient(circle at 0% 0%, var(--tw-gradient-stops))",
}, },
}, },
daisyui: {
themes: [
{
light: {
primary: "#06659d",
secondary: "#4b92db",
accent: "#F3C135",
neutral: "#2a323c",
"base-100": "#ffffff",
"base-200": "#f8f9fa",
"base-300": "#e9ecef",
info: "#3abff8",
success: "#36d399",
warning: "#fbbd23",
error: "#f87272",
},
dark: {
primary: "#88BFEC",
secondary: "#4b92db",
accent: "#F3C135",
neutral: "#191D24",
"base-100": "#0A0E1A",
"base-200": "#0d1324",
"base-300": "#1a2035",
info: "#3abff8",
success: "#36d399",
warning: "#fbbd23",
error: "#f87272",
},
},
],
},
}, },
plugins: [ plugins: [
require("tailwindcss-motion"), require("tailwindcss-motion"),