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/
.cursor
final_review_gate.py
# dependencies
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]
nixPkgs = ["nodejs_18", "bun"]
nixPkgs = ["nodejs_20", "bun"]
aptPkgs = ["curl", "wget"]

View file

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

View file

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

View file

@ -7,11 +7,12 @@ import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { Collections } from "../../../schemas/pocketbase/schema";
import { Icon } from "@iconify/react";
import toast from "react-hot-toast";
import type { Event, EventAttendee } from "../../../schemas/pocketbase";
import type { Event, EventAttendee, LimitedUser } from "../../../schemas/pocketbase";
// Extended Event interface with additional properties needed for this component
interface ExtendedEvent extends Event {
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.
@ -156,7 +157,12 @@ const EventCheckIn = () => {
);
// Store event code in local storage for offline check-in
try {
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
// For food events, we'll show the toast after food selection
@ -180,7 +186,7 @@ const EventCheckIn = () => {
<div>
<strong>Event with food found!</strong>
<p className="text-sm mt-1">{event.event_name}</p>
<p className="text-xs mt-1">Please select your food preference</p>
<p className="text-xs mt-1">Please select the food you ate (or will eat) at the event!</p>
</div>,
{ duration: 5000 }
);
@ -264,23 +270,61 @@ const EventCheckIn = () => {
totalPoints += attendee.points_earned || 0;
});
// Log the points update
// console.log(`Updating user points to: ${totalPoints}`);
// Update the LimitedUser record with the new points total
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
await update.updateFields(Collections.USERS, userId, {
points: totalPoints
// Create or update the LimitedUser record
if (limitedUserExists) {
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
// First sync the new attendance record
try {
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.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
try {
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
await logger.send(
@ -359,12 +403,12 @@ const EventCheckIn = () => {
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">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Event Check-in</h3>
<div className="w-full">
<label className="block text-sm sm:text-base mb-2">
<span className="text-sm sm:text-base">Enter event code to check in</span>
<div className="form-control w-full">
<label className="label">
<span className="label-text text-sm sm:text-base">Enter event code to check in</span>
</label>
<form onSubmit={(e) => {
e.preventDefault();
@ -428,12 +472,12 @@ const EventCheckIn = () => {
<div className="badge badge-primary mb-4">
{currentCheckInEvent?.points_to_reward} points
</div>
<p className="mb-4">This event has food! Please let us know what you'd like to eat:</p>
<p className="mb-4">This event has food! Please let us know what you ate (or will eat):</p>
<form onSubmit={handleSubmit}>
<div className="form-control">
<input
type="text"
placeholder="Enter your food preference"
placeholder="Enter the food you will or are eating"
className="input input-bordered w-full"
value={foodInput}
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
interface ExtendedEvent extends Event {
description?: string; // This component uses 'description' but schema has 'event_description'
event_type: string; // Add event_type field from schema
}
declare global {
@ -62,6 +63,19 @@ const EventLoad = () => {
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
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
const refreshEvents = async () => {
@ -103,28 +117,28 @@ const EventLoad = () => {
}, []);
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="flex flex-col h-full">
<div className="flex items-start justify-between gap-3 mb-2">
<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="skeleton h-5 w-16 bg-base-300 dark:bg-gray-700"></div>
<div className="skeleton h-5 w-20 bg-base-300 dark:bg-gray-700"></div>
<div className="skeleton h-5 w-16"></div>
<div className="skeleton h-5 w-20"></div>
</div>
</div>
<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-4 w-16 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"></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="skeleton h-4 w-4 bg-base-300 dark:bg-gray-700"></div>
<div className="skeleton h-4 w-1/2 bg-base-300 dark:bg-gray-700"></div>
<div className="skeleton h-4 w-4"></div>
<div className="skeleton h-4 w-1/2"></div>
</div>
</div>
</div>
@ -146,7 +160,7 @@ const EventLoad = () => {
try {
const get = Get.getInstance();
const attendees = await get.getList<EventAttendee>(
"event_attendees",
Collections.EVENT_ATTENDEES,
1,
1,
`user="${currentUser.id}" && event="${event.id}"`
@ -154,12 +168,38 @@ const EventLoad = () => {
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
const cardElement = document.getElementById(`event-card-${event.id}`);
if (cardElement && hasAttendedEvent) {
const attendedBadge = cardElement.querySelector('.attended-badge');
if (attendedBadge) {
(attendedBadge as HTMLElement).style.display = 'flex';
if (cardElement) {
const attendedBadge = document.getElementById(`attendance-badge-${event.id}`);
if (attendedBadge && hasAttendedEvent) {
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) {
@ -176,48 +216,87 @@ const EventLoad = () => {
const endDate = new Date(event.end_date);
const now = new Date();
const isPastEvent = endDate < now;
const isExpanded = expandedDescriptions.has(event.id);
const description = event.event_description || "No description available";
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 className="card-body p-3 sm:p-4">
<div className="flex flex-col h-full">
<div className="flex flex-col gap-2">
<div className="flex-1">
<h3 className="card-title text-base sm:text-lg font-semibold mb-1 line-clamp-2 text-gray-800 dark:text-gray-100">{event.event_name}</h3>
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm text-gray-600 dark:text-gray-300">
<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">
<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-4">
{/* Event Header */}
<div className="flex justify-between items-start mb-2">
<h3 className="card-title text-base sm:text-lg font-semibold line-clamp-2">{event.event_name}</h3>
<div className="badge badge-primary badge-sm flex-shrink-0">{event.points_to_reward} pts</div>
</div>
{/* 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", {
weekday: "short",
month: "short",
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", {
hour: "numeric",
minute: "2-digit",
})}
</span>
</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 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 className="text-xs sm:text-sm text-gray-600 dark:text-gray-300 my-2 line-clamp-2">
{event.event_description || "No description available"}
</div>
<div className="flex flex-wrap items-center gap-2 mt-auto pt-2">
{/* Action Buttons */}
<div className="flex flex-wrap items-center gap-2 mt-auto">
{event.files && event.files.length > 0 && (
<button
onClick={() => window.openDetailsModal(event as ExtendedEvent)}
className="btn btn-ghost btn-sm text-xs sm:text-sm gap-1 h-8 min-h-0 px-2 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" />
Files ({event.files.length})
</button>
)}
{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={hasAttended ? "heroicons:check-circle" : "heroicons:x-circle"}
className="h-3 w-3"
@ -225,10 +304,6 @@ const EventLoad = () => {
{hasAttended ? 'Attended' : 'Not Attended'}
</div>
)}
<div className="text-xs sm:text-sm text-gray-600 dark:text-gray-400 ml-auto">
{event.location}
</div>
</div>
</div>
</div>
</div>
@ -480,9 +555,9 @@ const EventLoad = () => {
return (
<>
{/* 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">
<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">
{[...Array(3)].map((_, i) => (
<div key={`ongoing-skeleton-${i}`}>{createSkeletonCard()}</div>
@ -492,9 +567,9 @@ const EventLoad = () => {
</div>
{/* 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">
<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">
{[...Array(3)].map((_, i) => (
<div key={`upcoming-skeleton-${i}`}>{createSkeletonCard()}</div>
@ -504,9 +579,9 @@ const EventLoad = () => {
</div>
{/* 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">
<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">
{[...Array(3)].map((_, i) => (
<div key={`past-skeleton-${i}`}>{createSkeletonCard()}</div>
@ -525,14 +600,14 @@ const EventLoad = () => {
<>
{/* No Events Message */}
{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">
<Icon icon="heroicons:calendar" className="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" />
<h3 className="text-xl font-bold mb-2 text-gray-800 dark:text-gray-100">No Events Found</h3>
<p className="text-gray-600 dark:text-gray-300 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">No Events Found</h3>
<p className="text-base-content/70 mb-4">
There are currently no events to display. This could be due to:
</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">There might be a connection issue with the event database</li>
<li className="mb-1">The events data might be temporarily unavailable</li>
@ -560,9 +635,9 @@ const EventLoad = () => {
{/* Ongoing Events */}
{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">
<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">
{events.ongoing.map(renderEventCard)}
</div>
@ -572,9 +647,9 @@ const EventLoad = () => {
{/* Upcoming Events */}
{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">
<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">
{events.upcoming.map(renderEventCard)}
</div>
@ -584,9 +659,9 @@ const EventLoad = () => {
{/* Past Events */}
{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">
<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">
{events.past.map(renderEventCard)}
</div>

View file

@ -1,6 +1,8 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { LimitedUser } from '../../../schemas/pocketbase/schema';
interface LeaderboardStats {
totalUsers: number;
@ -54,34 +56,50 @@ export default function LeaderboardStats() {
setLoading(true);
// 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']
});
// 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
const leaderboardUsers = response.items
.filter((user: any) =>
user.points !== undefined &&
user.points !== null &&
user.points > 0
)
const leaderboardUsers = processedUsers
.filter(user => user.parsedPoints > 0)
// 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 totalPoints = leaderboardUsers.reduce((sum: number, user: any) => sum + (user.points || 0), 0);
const topScore = leaderboardUsers.length > 0 ? leaderboardUsers[0].points : 0;
const totalPoints = leaderboardUsers.reduce((sum: number, user) => sum + user.parsedPoints, 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
let yourPoints = 0;
let yourRank = null;
if (isAuthenticated && currentUserId) {
// Look for the current user in ALL users, not just those with points > 0
const currentUser = response.items.find((user: any) => user.id === currentUserId);
// Look for the current user in ALL processed users, not just those with points > 0
const currentUser = processedUsers.find(user => user.id === currentUserId);
if (currentUser) {
yourPoints = currentUser.points || 0;
yourPoints = currentUser.parsedPoints || 0;
// Only calculate rank if user has points
if (yourPoints > 0) {
@ -119,15 +137,15 @@ export default function LeaderboardStats() {
};
fetchStats();
}, [isAuthenticated, currentUserId]);
}, [get, isAuthenticated, currentUserId]);
if (loading) {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[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 className="h-4 w-24 bg-base-300 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 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-gray-200 dark:bg-gray-700 rounded mb-2 mt-4 mx-4"></div>
<div className="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded mx-4"></div>
</div>
))}
</div>
@ -136,27 +154,27 @@ export default function LeaderboardStats() {
return (
<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="text-sm font-medium text-gray-700 dark:text-gray-300">Total Members</div>
<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-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-1 text-xs text-gray-500 dark:text-gray-400">In the leaderboard</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="text-sm font-medium text-gray-700 dark:text-gray-300">Total Points</div>
<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-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-1 text-xs text-gray-500 dark:text-gray-400">Earned by all members</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="text-sm font-medium text-gray-700 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="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-600 dark:text-gray-300">Top Score</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>
<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="text-sm font-medium text-gray-700 dark:text-gray-300">Your Score</div>
<div className="mt-2 text-3xl font-bold text-primary dark:text-primary">
<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-600 dark:text-gray-300">Your Score</div>
<div className="mt-2 text-3xl font-bold text-indigo-600 dark:text-indigo-400">
{isAuthenticated ? stats.yourPoints : '-'}
</div>
<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 { Get } from '../../../scripts/pocketbase/Get';
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 {
id: string;
@ -63,21 +64,44 @@ export default function LeaderboardTable() {
setLoading(true);
// 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']
});
// First get the current user separately so we can include them even if they have 0 points
let currentUserData = null;
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
let leaderboardUsers = response.items
.filter((user: Partial<User>) => user.points !== undefined && user.points !== null && user.points > 0)
.sort((a: Partial<User>, b: Partial<User>) => (b.points || 0) - (a.points || 0))
.map((user: Partial<User>, index: number) => {
let leaderboardUsers = processedUsers
.filter(user => user.parsedPoints > 0)
.sort((a, b) => (b.parsedPoints || 0) - (a.parsedPoints || 0))
.map((user, index: number) => {
// Check if this is the current user
if (isAuthenticated && user.id === currentUserId) {
setCurrentUserRank(index + 1);
@ -86,7 +110,7 @@ export default function LeaderboardTable() {
return {
id: user.id || '',
name: user.name || 'Anonymous User',
points: user.points || 0,
points: user.parsedPoints,
avatar: user.avatar,
major: user.major
};
@ -94,17 +118,21 @@ export default function LeaderboardTable() {
// Include current user even if they have 0 points,
// but don't include in ranking if they have no points
if (isAuthenticated && currentUserData &&
!leaderboardUsers.some(user => user.id === currentUserId)) {
// User isn't already in the list (has 0 points)
if (isAuthenticated && currentUserId) {
// Find current user in processed users
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({
id: currentUserData.id || '',
name: currentUserData.name || 'Anonymous User',
points: currentUserData.points || 0,
avatar: currentUserData.avatar,
major: currentUserData.major
id: currentUserProcessed.id || '',
name: currentUserProcessed.name || 'Anonymous User',
points: currentUserProcessed.parsedPoints || 0,
avatar: currentUserProcessed.avatar,
major: currentUserProcessed.major
});
}
}
setUsers(leaderboardUsers);
setFilteredUsers(leaderboardUsers);
@ -117,7 +145,7 @@ export default function LeaderboardTable() {
};
fetchLeaderboard();
}, [isAuthenticated, currentUserId]);
}, [get, isAuthenticated, currentUserId]);
useEffect(() => {
if (searchQuery.trim() === '') {
@ -184,37 +212,37 @@ export default function LeaderboardTable() {
<input
type="text"
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
bg-base-100 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"
className="w-full pl-10 pr-4 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-indigo-500 focus:border-transparent"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{/* 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">
<thead className="bg-base-200 dark:bg-gray-800/80">
<thead className="bg-gray-50/80 dark:bg-gray-800/80">
<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
</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
</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
</th>
</tr>
</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) => {
const actualRank = user.points > 0 ? indexOfFirstUser + index + 1 : null;
const isCurrentUser = user.id === currentUserId;
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">
{actualRank ? (
actualRank <= 3 ? (
@ -233,7 +261,7 @@ export default function LeaderboardTable() {
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<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 ? (
<img className="h-10 w-10 rounded-full" src={user.avatar} alt={user.name} />
) : (
@ -255,7 +283,7 @@ export default function LeaderboardTable() {
</div>
</div>
</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}
</td>
</tr>
@ -270,8 +298,8 @@ export default function LeaderboardTable() {
<div className="flex justify-center mt-6">
<nav className="flex items-center">
<button
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-base-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"
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 dark:border-gray-700
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))}
disabled={currentPage === 1}
>
@ -284,11 +312,11 @@ export default function LeaderboardTable() {
{Array.from({ length: totalPages }, (_, i) => (
<button
key={i + 1}
className={`relative inline-flex items-center px-4 py-2 border border-base-300 dark:border-gray-700
bg-base-100 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-gray-700 dark:text-gray-300 hover:bg-base-200 dark:hover:bg-gray-700'
} shadow-sm`}
className={`relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-700
bg-white/90 dark:bg-gray-800/90 text-sm font-medium ${currentPage === i + 1
? '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-gray-50 dark:hover:bg-gray-700'
}`}
onClick={() => paginate(i + 1)}
>
{i + 1}
@ -296,8 +324,8 @@ export default function LeaderboardTable() {
))}
<button
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-base-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"
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 dark:border-gray-700
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))}
disabled={currentPage === totalPages}
>
@ -312,9 +340,9 @@ export default function LeaderboardTable() {
{/* Show current user rank if not in current page */}
{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">
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>
</div>
)}
@ -323,7 +351,7 @@ export default function LeaderboardTable() {
{isAuthenticated && currentUserId &&
!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">
Participate in events to earn points and get ranked!
</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;
// Determine quarter (0-based months: 0-11)
// Q1: Sept-Dec (8-11)
// Q2: Jan-Mar (0-2)
// Q3: Mar-Jun (2-5)
// Q4: Jun-Sept (5-8)
// Fall: Sept-Dec (8-11)
// Winter: Jan-Mar (0-2)
// Spring: Apr-Jun (3-5)
// Summer: Jul-Sept (6-8)
if (month >= 8) {
// Q1: Sept-Dec
// Fall: Sept-Dec
start = new Date(year, 8, 1);
end = new Date(year, 11, 31);
} else if (month < 2) {
// Q2: Jan-Mar
} else if (month >= 0 && month < 3) {
// Winter: Jan-Mar
start = new Date(year, 0, 1);
end = new Date(year, 2, 31);
} else if (month < 5) {
// Q3: Mar-Jun
start = new Date(year, 2, 1);
} else if (month >= 3 && month < 6) {
// Spring: Apr-Jun
start = new Date(year, 3, 1);
end = new Date(year, 5, 30);
} else {
// Q4: Jun-Sept
start = new Date(year, 5, 1);
end = new Date(year, 8, 0); // End on Aug 31
// Summer: Jul-Sept
start = new Date(year, 6, 1);
end = new Date(year, 8, 30);
}
return { start, end };
@ -924,14 +924,14 @@ const currentPage = eventResponse.page;
if (month >= 8) {
// Sept-Dec
return "Fall";
} else if (month < 2) {
} else if (month >= 0 && month < 3) {
// Jan-Mar
return "Winter";
} else if (month < 5) {
// Mar-Jun
} else if (month >= 3 && month < 6) {
// Apr-Jun
return "Spring";
} else {
// Jun-Sept
// Jul-Sept
return "Summer";
}
}
@ -979,13 +979,13 @@ const currentPage = eventResponse.page;
isInQuarter = month >= 8 && month <= 11; // Sept-Dec
break;
case "winter":
isInQuarter = month >= 0 && month <= 2; // Jan-Mar
isInQuarter = month >= 0 && month < 3; // Jan-Mar (0-2)
break;
case "spring":
isInQuarter = month >= 2 && month <= 5; // Mar-Jun
isInQuarter = month >= 3 && month < 6; // Apr-Jun (3-5)
break;
case "summer":
isInQuarter = month >= 5 && month <= 8; // Jun-Sept
isInQuarter = month >= 6 && month < 9; // Jul-Sept (6-8)
break;
}
if (isInQuarter) {

View file

@ -5,6 +5,7 @@ import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { Update } from "../../../scripts/pocketbase/Update";
import { FileManager } from "../../../scripts/pocketbase/FileManager";
import { SendLog } from "../../../scripts/pocketbase/SendLog";
import { Realtime } from "../../../scripts/pocketbase/Realtime";
import FilePreview from "../universal/FilePreview";
import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase";
import { DataSyncService } from '../../../scripts/database/DataSyncService';
@ -132,6 +133,28 @@ const EventForm = memo(({
/>
</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 */}
<div className="form-control">
<label className="label">
@ -142,7 +165,7 @@ const EventForm = memo(({
type="number"
name="editEventPoints"
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))}
min="0"
required
@ -240,7 +263,15 @@ const EventForm = memo(({
// Show error for rejected files
if (rejectedFiles.length > 0) {
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);
@ -293,6 +324,31 @@ const EventForm = memo(({
>
<Icon icon="heroicons:eye" className="h-4 w-4" />
</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">
{filesToDelete.has(filename) ? (
<button
@ -401,6 +457,7 @@ interface EventChanges {
end_date?: string;
published?: boolean;
has_food?: boolean;
event_type?: string;
}
interface FileChanges {
@ -487,7 +544,8 @@ class ChangeTracker {
'start_date',
'end_date',
'published',
'has_food'
'has_food',
'event_type'
];
for (const field of fields) {
@ -550,11 +608,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
event_code: "",
location: "",
files: [],
points_to_reward: 0,
points_to_reward: null as unknown as number,
start_date: "",
end_date: "",
published: false,
has_food: false
has_food: false,
event_type: "other"
});
const [previewUrl, setPreviewUrl] = useState("");
@ -571,7 +630,8 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
auth: Authentication.getInstance(),
update: Update.getInstance(),
fileManager: FileManager.getInstance(),
sendLog: SendLog.getInstance()
sendLog: SendLog.getInstance(),
realtime: Realtime.getInstance()
}), []);
// Handle field changes
@ -590,17 +650,35 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
const initializeEventData = useCallback(async (eventId: string) => {
try {
if (eventId) {
// Show loading state
setIsSubmitting(true);
// Clear cache to ensure fresh data
const dataSync = DataSyncService.getInstance();
await dataSync.clearCache();
// Fetch fresh event data
const eventData = await services.get.getOne<Event>(Collections.EVENTS, eventId);
// Fetch fresh event data with expanded relations if needed
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) {
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
if (eventData.start_date) {
// Convert to Date object first to ensure proper formatting
@ -624,15 +702,44 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
event_code: eventData.event_code || '',
location: eventData.location || '',
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 || '',
end_date: eventData.end_date || '',
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);
} else {
// Creating a new event
const now = new Date();
const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
setEvent({
id: '',
created: '',
@ -642,11 +749,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
event_code: '',
location: '',
files: [],
points_to_reward: 0,
start_date: '',
end_date: '',
points_to_reward: null as unknown as number,
start_date: Get.formatLocalDate(now, false),
end_date: Get.formatLocalDate(oneHourLater, false),
published: false,
has_food: false
has_food: false,
event_type: "other"
});
}
setSelectedFiles(new Map());
@ -656,8 +764,10 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
} catch (error) {
console.error("Failed to initialize event data:", error);
toast.error("Failed to load event data. Please try again.");
} finally {
setIsSubmitting(false);
}
}, [services.get]);
}, [services]);
// Expose initializeEventData to window
useEffect(() => {
@ -698,6 +808,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
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({
id: "",
created: "",
@ -707,11 +823,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
event_code: "",
location: "",
files: [],
points_to_reward: 0,
points_to_reward: null as unknown as number,
start_date: "",
end_date: "",
published: false,
has_food: false
has_food: false,
event_type: "other"
});
setSelectedFiles(new Map());
setFilesToDelete(new Set());
@ -719,12 +836,24 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
setPreviewUrl("");
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;
if (modal) modal.close();
}, [hasUnsavedChanges, isSubmitting]);
}, [hasUnsavedChanges, isSubmitting, services.realtime]);
// Function to close modal after saving (without confirmation)
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({
id: "",
created: "",
@ -734,11 +863,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
event_code: "",
location: "",
files: [],
points_to_reward: 0,
points_to_reward: null as unknown as number,
start_date: "",
end_date: "",
published: false,
has_food: false
has_food: false,
event_type: "other"
});
setSelectedFiles(new Map());
setFilesToDelete(new Set());
@ -746,9 +876,15 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
setPreviewUrl("");
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;
if (modal) modal.close();
}, []);
}, [services.realtime]);
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@ -778,11 +914,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
event_code: formData.get("editEventCode") as string,
location: formData.get("editEventLocation") as string,
files: event.files || [],
points_to_reward: parseInt(formData.get("editEventPoints") as string) || 0,
points_to_reward: formData.get("editEventPoints") ? parseInt(formData.get("editEventPoints") as string) : null as unknown as number,
start_date: formData.get("editEventStartDate") as string,
end_date: formData.get("editEventEndDate") as string,
published: formData.get("editEventPublished") === "on",
has_food: formData.get("editEventHasFood") === "on"
has_food: formData.get("editEventHasFood") === "on",
event_type: formData.get("editEventType") as string || "other"
};
// Log the update attempt

View file

@ -70,6 +70,12 @@ interface ASFundingSectionProps {
}
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 [jsonInput, setJsonInput] = useState<string>('');
const [jsonError, setJsonError] = useState<string>('');
@ -80,11 +86,26 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
const handleInvoiceFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const newFiles = Array.from(e.target.files) as File[];
setInvoiceFiles(newFiles);
onDataChange({ invoice_files: newFiles });
// Combine existing files with new files instead of replacing
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
const handleJsonInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setJsonInput(e.target.value);
@ -122,6 +143,19 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
};
// 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 = () => {
try {
if (!jsonInput.trim()) {
@ -181,6 +215,9 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
total: data.total
}, null, 2);
// Check budget limits and show toast if needed
checkBudgetLimit(data.total);
// Apply the JSON data to the form
onDataChange({
invoiceData: data,
@ -212,15 +249,17 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const newFiles = Array.from(e.dataTransfer.files) as File[];
setInvoiceFiles(newFiles);
onDataChange({ invoice_files: newFiles });
// Combine existing files with new files instead of replacing
const combinedFiles = [...invoiceFiles, ...newFiles];
setInvoiceFiles(combinedFiles);
onDataChange({ invoice_files: combinedFiles });
}
};
// Handle invoice data change from the invoice builder
const handleInvoiceDataChange = (data: InvoiceData) => {
// Calculate if budget exceeds maximum allowed
const maxBudget = Math.min(formData.expected_attendance * 10, 5000);
// Check budget limits and show toast if needed
checkBudgetLimit(data.total);
onDataChange({
invoiceData: data,
@ -289,20 +328,44 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
{invoiceFiles.length > 0 ? (
<>
<div className="flex items-center justify-between w-full">
<p className="font-medium text-primary">{invoiceFiles.length} file(s) selected:</p>
<div className="max-h-24 overflow-y-auto text-left w-full">
<ul className="list-disc list-inside text-sm">
{invoiceFiles.map((file, index) => (
<li key={index} className="truncate">{file.name}</li>
))}
</ul>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleClearAllFiles();
}}
className="btn btn-xs btn-outline btn-error"
title="Clear all files"
>
Clear All
</button>
</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="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>

View file

@ -129,7 +129,27 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
type="datetime-local"
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
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
whileHover="hover"
variants={inputHoverVariants}
@ -155,25 +175,59 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
<motion.input
type="time"
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) => {
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
const startDate = new Date(formData.start_date_time);
if (isNaN(startDate.getTime())) {
console.error('Invalid start date time');
return;
}
// Parse the time value
const [hours, minutes] = e.target.value.split(':').map(Number);
// Set the hours and minutes on the date
startDate.setHours(hours, minutes);
const [hours, minutes] = timeValue.split(':').map(Number);
// 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
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
disabled={!formData.start_date_time}
whileHover="hover"
variants={inputHoverVariants}
/>
<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>
</div>
</motion.div>

View file

@ -7,6 +7,7 @@ import { FileManager } from '../../../scripts/pocketbase/FileManager';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import { EventRequestStatus } from '../../../schemas/pocketbase';
import { EmailClient } from '../../../scripts/email/EmailClient';
// Form sections
import PRSection from './PRSection';
@ -69,13 +70,13 @@ export interface EventRequestFormData {
flyer_advertising_start_date: string;
flyer_additional_requests: 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;
will_or_have_room_booking: boolean;
expected_attendance: number;
room_booking: File | null;
room_booking_files: File[]; // CHANGED: Multiple room booking files instead of single
invoice: File | null;
invoice_files: File[];
invoice_files: File[]; // MULTIPLE FILES
invoiceData: InvoiceData;
needs_graphics?: boolean | null;
needs_as_funding?: boolean | null;
@ -88,7 +89,6 @@ import CustomAlert from '../universal/CustomAlert';
const EventRequestForm: React.FC = () => {
const [currentStep, setCurrentStep] = useState<number>(1);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Initialize form data
const [formData, setFormData] = useState<EventRequestFormData>({
@ -108,7 +108,7 @@ const EventRequestForm: React.FC = () => {
advertising_format: '',
will_or_have_room_booking: false,
expected_attendance: 0,
room_booking: null,
room_booking_files: [],
as_funding_required: false,
food_drinks_being_served: false,
itemized_invoice: '',
@ -134,9 +134,10 @@ const EventRequestForm: React.FC = () => {
const dataToStore = {
...formDataToSave,
other_logos: [],
room_booking: null,
room_booking_files: [],
invoice: null,
invoice_files: []
invoice_files: [],
savedAt: Date.now() // Add timestamp for stale data detection
};
localStorage.setItem('eventRequestFormData', JSON.stringify(dataToStore));
@ -153,12 +154,27 @@ const EventRequestForm: React.FC = () => {
if (savedData) {
try {
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 => ({
...prevData,
...parsedData
}));
}
} catch (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 => {
// Save to localStorage
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;
});
};
@ -202,7 +238,7 @@ const EventRequestForm: React.FC = () => {
advertising_format: '',
will_or_have_room_booking: false,
expected_attendance: 0,
room_booking: null, // No room booking by default
room_booking_files: [],
as_funding_required: false,
food_drinks_being_served: false,
itemized_invoice: '',
@ -236,7 +272,6 @@ const EventRequestForm: React.FC = () => {
}
setIsSubmitting(true);
setError(null);
try {
const auth = Authentication.getInstance();
@ -267,8 +302,36 @@ const EventRequestForm: React.FC = () => {
requested_user: userId,
name: formData.name,
location: formData.location,
start_date_time: new Date(formData.start_date_time).toISOString(),
end_date_time: formData.end_date_time ? new Date(formData.end_date_time).toISOString() : new Date(formData.start_date_time).toISOString(),
start_date_time: (() => {
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,
flyers_needed: formData.flyers_needed,
photography_needed: formData.photography_needed,
@ -277,7 +340,14 @@ const EventRequestForm: React.FC = () => {
itemized_invoice: formData.itemized_invoice,
flyer_type: formData.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,
required_logos: formData.required_logos,
advertising_format: formData.advertising_format,
@ -302,36 +372,126 @@ const EventRequestForm: React.FC = () => {
// This will send the data to the server
const record = await update.create('event_request', submissionData);
// Force sync the event requests collection to update IndexedDB
await dataSync.syncCollection(Collections.EVENT_REQUESTS);
// Force sync the event requests collection to update IndexedDB with deletion detection
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) {
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);
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) {
await fileManager.uploadFile('event_request', record.id, 'room_booking', formData.room_booking);
// Upload room booking files
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) {
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
if (formData.invoice || formData.invoice_files[0]) {
const mainInvoice = formData.invoice || formData.invoice_files[0];
await fileManager.uploadFile('event_request', record.id, 'invoice', mainInvoice);
// Use the correct field name 'invoice' instead of 'invoice_files'
await fileManager.uploadFiles('event_request', record.id, 'invoice', formData.invoice_files);
console.log('Invoice files uploaded successfully');
} 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) {
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);
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
localStorage.removeItem('eventRequestFormData');
// Keep success toast for form submission since it's a user action
toast.success('Event request submitted successfully!');
// Send email notification to coordinators (non-blocking)
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
resetForm();
@ -344,7 +504,6 @@ const EventRequestForm: React.FC = () => {
} catch (error) {
console.error('Error submitting event request:', error);
toast.error('Failed to submit event request. Please try again.');
setError('Failed to submit event request. Please try again.');
} finally {
setIsSubmitting(false);
}
@ -407,11 +566,47 @@ const EventRequestForm: React.FC = () => {
if (!formData.start_date_time || formData.start_date_time.trim() === '') {
errors.push('Event start date and time is required');
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');
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() === '') {
@ -419,13 +614,14 @@ const EventRequestForm: React.FC = () => {
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');
valid = false;
}
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;
}
@ -445,9 +641,9 @@ const EventRequestForm: React.FC = () => {
return false;
}
// Only require room booking file if will_or_have_room_booking is true
if (formData.will_or_have_room_booking && !formData.room_booking) {
toast.error('Please upload your room booking confirmation');
// REQUIRED: Room booking files if will_or_have_room_booking is true
if (formData.will_or_have_room_booking && formData.room_booking_files.length === 0) {
toast.error('Room booking files are required when you need a room booking');
return false;
}
@ -467,10 +663,16 @@ const EventRequestForm: React.FC = () => {
// Validate AS Funding Section
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
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;
}
@ -482,7 +684,7 @@ const EventRequestForm: React.FC = () => {
// Check if the budget exceeds the maximum allowed ($5000 cap regardless of attendance)
const maxBudget = Math.min(formData.expected_attendance * 10, 5000);
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;
}
}
@ -813,21 +1015,6 @@ const EventRequestForm: React.FC = () => {
}}
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 */}
<div className="w-full mb-6">
<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 { FlyerTypes, LogoOptions } from '../../../schemas/pocketbase';
import CustomAlert from '../universal/CustomAlert';
import { Icon } from '@iconify/react';
// Enhanced animation variants
const containerVariants = {
@ -122,11 +123,26 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
const handleLogoFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const newFiles = Array.from(e.target.files) as File[];
setOtherLogoFiles(newFiles);
onDataChange({ other_logos: newFiles });
// Combine existing files with new files instead of replacing
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
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
@ -144,8 +160,10 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const newFiles = Array.from(e.dataTransfer.files) as File[];
setOtherLogoFiles(newFiles);
onDataChange({ other_logos: newFiles });
// Combine existing files with new files instead of replacing
const combinedFiles = [...otherLogoFiles, ...newFiles];
setOtherLogoFiles(combinedFiles);
onDataChange({ other_logos: combinedFiles });
}
};
@ -349,20 +367,44 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
{otherLogoFiles.length > 0 ? (
<>
<div className="flex items-center justify-between w-full">
<p className="font-medium text-primary">{otherLogoFiles.length} file(s) selected:</p>
<div className="max-h-24 overflow-y-auto text-left w-full">
<ul className="list-disc list-inside text-sm">
{otherLogoFiles.map((file, index) => (
<li key={index} className="truncate">{file.name}</li>
))}
</ul>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleClearAllLogoFiles();
}}
className="btn btn-xs btn-outline btn-error"
title="Clear all files"
>
Clear All
</button>
</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="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>

View file

@ -4,6 +4,7 @@ import type { EventRequestFormData } from './EventRequestForm';
import type { EventRequest } from '../../../schemas/pocketbase';
import CustomAlert from '../universal/CustomAlert';
import FilePreview from '../universal/FilePreview';
import { Icon } from '@iconify/react';
// Enhanced animation variants
const containerVariants = {
@ -69,11 +70,12 @@ interface TAPFormSectionProps {
}
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 [fileError, setFileError] = useState<string | null>(null);
const [showFilePreview, setShowFilePreview] = useState(false);
const [filePreviewUrl, setFilePreviewUrl] = useState<string | null>(null);
const [selectedPreviewFile, setSelectedPreviewFile] = useState<File | null>(null);
// Add style tag for hidden arrows
useEffect(() => {
@ -89,27 +91,58 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
// Handle room booking file upload with size limit
const handleRoomBookingFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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
if (file.size > 1024 * 1024) {
setFileError("Room booking file size must be under 1MB");
// Check file sizes - 1MB limit for each file
const oversizedFiles = newFiles.filter(file => file.size > 1024 * 1024);
if (oversizedFiles.length > 0) {
setFileError(`${oversizedFiles.length} file(s) exceed 1MB limit`);
return;
}
setFileError(null);
setRoomBookingFile(file);
onDataChange({ room_booking: file });
// Combine existing files with new files instead of replacing
const combinedFiles = [...roomBookingFiles, ...newFiles];
setRoomBookingFiles(combinedFiles);
onDataChange({ room_booking_files: combinedFiles });
// Create preview URL
// Create preview URL for the first new file
if (filePreviewUrl) {
URL.revokeObjectURL(filePreviewUrl);
}
const url = URL.createObjectURL(file);
const url = URL.createObjectURL(newFiles[0]);
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
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
@ -126,24 +159,28 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
setIsDragging(false);
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
if (file.size > 1024 * 1024) {
setFileError("Room booking file size must be under 1MB");
// Check file sizes - 1MB limit for each file
const oversizedFiles = newFiles.filter(file => file.size > 1024 * 1024);
if (oversizedFiles.length > 0) {
setFileError(`${oversizedFiles.length} file(s) exceed 1MB limit`);
return;
}
setFileError(null);
setRoomBookingFile(file);
onDataChange({ room_booking: file });
// Combine existing files with new files instead of replacing
const combinedFiles = [...roomBookingFiles, ...newFiles];
setRoomBookingFiles(combinedFiles);
onDataChange({ room_booking_files: combinedFiles });
// Create preview URL
// Create preview URL for the first new file
if (filePreviewUrl) {
URL.revokeObjectURL(filePreviewUrl);
}
const url = URL.createObjectURL(file);
const url = URL.createObjectURL(newFiles[0]);
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>
{formData.will_or_have_room_booking && <span className="label-text-alt text-error">*</span>}
</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 && (
<div className="mt-2 mb-2">
@ -292,6 +334,7 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
className="hidden"
onChange={handleRoomBookingFileChange}
accept=".pdf,.png,.jpg,.jpeg"
multiple
/>
<div className="flex flex-col items-center justify-center gap-3">
@ -304,16 +347,46 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
</svg>
</motion.div>
{roomBookingFile ? (
{roomBookingFiles.length > 0 ? (
<>
<p className="font-medium text-primary">File selected:</p>
<p className="text-sm">{roomBookingFile.name}</p>
<p className="text-xs text-gray-500">Click or drag to replace (Max size: 1MB)</p>
<div className="flex items-center justify-between w-full">
<p className="font-medium text-primary">{roomBookingFiles.length} file(s) selected:</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="text-xs text-gray-500">Accepted formats: PDF, PNG, JPG (Max size: 1MB)</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 each, multiple files allowed)</p>
</>
)}
</div>
@ -329,20 +402,20 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
)}
{/* 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">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={toggleFilePreview}
>
{showFilePreview ? 'Hide Preview' : 'Preview File'}
{showFilePreview ? 'Hide Preview' : `Preview Files (${roomBookingFiles.length})`}
</button>
</div>
)}
{/* File Preview Component */}
{showFilePreview && filePreviewUrl && roomBookingFile && (
{showFilePreview && roomBookingFiles.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
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"
>
<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
type="button"
className="btn btn-sm btn-circle"
@ -361,7 +434,17 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
</svg>
</button>
</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>

View file

@ -258,12 +258,14 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
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>(
Collections.EVENT_REQUESTS,
true, // Force sync
`requested_user="${userId}"`,
'-created'
'-created',
{}, // expand
true // Enable deletion detection for user-specific requests
);
setEventRequests(updatedRequests);

View file

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

View file

@ -1,12 +1,11 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion } from 'framer-motion';
import toast from 'react-hot-toast';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } 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 CustomAlert from '../universal/CustomAlert';
import UniversalFilePreview from '../universal/FilePreview';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
// 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
flyer_files?: string[]; // Add this for PR-related files
files?: string[]; // Generic files field
room_reservation_needed?: boolean;
room_reservation_location?: string;
room_reservation_confirmed?: boolean;
will_or_have_room_booking?: boolean;
room_booking_files?: string[]; // CHANGED: Multiple room booking files instead of single
room_reservation_needed?: boolean; // Keep for backward compatibility
additional_notes?: string;
flyers_completed?: boolean; // Track if flyers have been completed by PR team
}
interface EventRequestDetailsProps {
@ -82,7 +82,7 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
setFileUrl(secureUrl);
// Determine file type from extension
const extension = fileName.split('.').pop()?.toLowerCase() || '';
const extension = (typeof fileName === 'string' ? fileName.split('.').pop()?.toLowerCase() : '') || '';
setFileType(extension);
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" />
)}
<div className="flex-grow">
<div className="flex-1 min-w-0">
<p className="font-medium truncate" title={fileId}>
{displayName}
</p>
@ -712,6 +712,28 @@ const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request })
</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 */}
<FilePreviewModal
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
const InvoiceTable: React.FC<{ invoiceData: any, expectedAttendance?: number }> = ({ invoiceData, expectedAttendance }) => {
// 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 [isPreviewModalOpen, setIsPreviewModalOpen] = useState<boolean>(false);
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
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
const getFileExtension = (filename: string): string => {
if (!filename || typeof filename !== 'string') return '';
const parts = filename.split('.');
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 => {
if (!filename || typeof filename !== 'string') return 'Unknown File';
const basename = filename.split('/').pop() || filename;
if (basename.length <= maxLength) return basename;
const extension = getFileExtension(basename);
@ -993,6 +1187,46 @@ const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }
</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 && (
<motion.div
className="space-y-4"
@ -1270,6 +1504,9 @@ const EventRequestDetails = ({
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
const [newStatus, setNewStatus] = useState<"submitted" | "pending" | "completed" | "declined">("pending");
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 }>({
show: false,
type: "info",
@ -1300,8 +1537,14 @@ const EventRequestDetails = ({
};
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);
setIsConfirmModalOpen(true);
}
};
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 (
<div className="bg-transparent w-full">
{/* Tabs navigation */}
@ -1479,6 +1788,11 @@ const EventRequestDetails = ({
<label className="text-xs text-gray-400">Start Date & Time</label>
<p className="text-white">{formatDate(request.start_date_time)}</p>
</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>
@ -1503,14 +1817,14 @@ const EventRequestDetails = ({
</div>
<div className="flex items-center justify-between bg-base-200/30 p-3 rounded-lg">
<p className="text-white">Room Reservation Needed</p>
<div className={`badge ${request.room_reservation_needed ? 'badge-success' : 'badge-ghost'}`}>
{request.room_reservation_needed ? 'Yes' : 'No'}
<div className={`badge ${request.will_or_have_room_booking ? 'badge-success' : 'badge-ghost'}`}>
{request.will_or_have_room_booking ? 'Yes' : 'No'}
</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">
<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" />
@ -1519,14 +1833,40 @@ const EventRequestDetails = ({
<div className="space-y-3">
<div className="bg-base-200/30 p-3 rounded-lg">
<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 className="bg-base-200/30 p-3 rounded-lg">
<label className="text-xs text-gray-400 block mb-1">Confirmation Status</label>
<div className="flex items-center gap-2">
<div className={`badge ${request.room_reservation_confirmed ? 'badge-success' : 'badge-warning'}`}>
{request.room_reservation_confirmed ? 'Confirmed' : 'Pending'}
<div className={`badge ${request.room_booking_files && request.room_booking_files.length > 0 ? 'badge-success' : 'badge-warning'}`}>
{request.room_booking_files && request.room_booking_files.length > 0 ? 'Booking Files Uploaded' : 'No Booking Files'}
</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>
@ -1617,6 +1957,73 @@ const EventRequestDetails = ({
</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>
);
};

View file

@ -1,11 +1,8 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Get } from '../../../scripts/pocketbase/Get';
import { Update } from '../../../scripts/pocketbase/Update';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import toast from 'react-hot-toast';
import EventRequestDetails from './EventRequestDetails';
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
import { Collections } from '../../../schemas/pocketbase/schema';
@ -27,6 +24,8 @@ interface ExtendedEventRequest extends SchemaEventRequest {
invoice_data?: any;
invoice_files?: string[]; // Array of invoice file IDs
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 {
@ -45,14 +44,18 @@ const EventRequestManagementTable = ({
const [eventRequests, setEventRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
const [filteredRequests, setFilteredRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('active');
const [searchTerm, setSearchTerm] = useState<string>('');
const [sortField, setSortField] = useState<string>('created');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [sortField, setSortField] = useState<string>('start_date_time');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const dataSync = DataSyncService.getInstance();
// Add state for update modal
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState<boolean>(false);
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
const refreshEventRequests = async () => {
@ -65,13 +68,14 @@ const EventRequestManagementTable = ({
// 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>(
Collections.EVENT_REQUESTS,
true, // Force sync
'', // No filter
'', // No filter - get all requests
'-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
@ -133,9 +137,19 @@ const EventRequestManagementTable = ({
// Apply status filter
if (statusFilter !== 'all') {
filtered = filtered.filter(request =>
request.status?.toLowerCase() === statusFilter.toLowerCase()
);
if (statusFilter === 'active') {
// 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
@ -180,40 +194,125 @@ const EventRequestManagementTable = ({
};
// 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 {
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 eventName = eventRequest?.name || 'Event';
// Update local state
setEventRequests(prev =>
prev.map(request =>
request.id === id ? { ...request, status } : request
request.id === id ? { ...request, flyers_completed: completed } : request
)
);
setFilteredRequests(prev =>
prev.map(request =>
request.id === id ? { ...request, status } : request
request.id === id ? { ...request, flyers_completed: completed } : request
)
);
// Force sync to update IndexedDB
await dataSync.syncCollection<ExtendedEventRequest>(Collections.EVENT_REQUESTS);
toast.success(`"${eventName}" PR status updated to ${completed ? 'completed' : 'pending'}`);
// 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) {
// Find the event request to get its name
const eventRequest = eventRequests.find(req => req.id === id);
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
console.error('Error updating PR status:', error);
toast.error('Failed to update PR status');
}
};
@ -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
const getStatusBadge = (status?: "submitted" | "pending" | "completed" | "declined") => {
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
useEffect(() => {
applyFilters();
}, [statusFilter, searchTerm, sortField, sortDirection]);
}, [statusFilter, searchTerm, sortField, sortDirection, eventRequests]);
// Check authentication and refresh token if needed
useEffect(() => {
@ -450,6 +625,7 @@ const EventRequestManagementTable = ({
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="active">Active (Submitted & Pending)</option>
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
<option value="completed">Completed</option>
@ -492,7 +668,7 @@ const EventRequestManagementTable = ({
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">
<tr>
<th
@ -513,7 +689,7 @@ const EventRequestManagementTable = ({
onClick={() => handleSortChange('start_date_time')}
>
<div className="flex items-center gap-1">
Date
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">
<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>
</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="cursor-pointer hover:bg-base-300 transition-colors hidden md:table-cell"
@ -562,7 +751,7 @@ const EventRequestManagementTable = ({
)}
</div>
</th>
<th>Actions</th>
<th className="w-20 min-w-[5rem]">Actions</th>
</tr>
</thead>
<tbody>
@ -573,7 +762,11 @@ const EventRequestManagementTable = ({
{truncateText(request.name, 30)}
</div>
</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>
{(() => {
const { name, email } = getUserDisplayInfo(request);
@ -592,6 +785,28 @@ const EventRequestManagementTable = ({
<span className="badge badge-ghost badge-sm">No</span>
)}
</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">
{request.as_funding_required ? (
<span className="badge badge-success badge-sm">Yes</span>
@ -606,16 +821,17 @@ const EventRequestManagementTable = ({
</span>
</td>
<td>
<div className="flex items-center gap-2">
<div className="flex items-center justify-center">
<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)}
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="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>
View
<span className="hidden sm:inline">View</span>
</button>
</div>
</td>
@ -625,6 +841,50 @@ const EventRequestManagementTable = ({
</table>
</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 EventRequestManagementTable from './EventRequestManagementTable';
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 { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Get } from '../../../scripts/pocketbase/Get';
import { toast } from 'react-hot-toast';
import { EmailClient } from '../../../scripts/email/EmailClient';
import type { EventRequest } from '../../../schemas/pocketbase/schema';
// 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();
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();
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
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
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
document.dispatchEvent(
new CustomEvent("status-updated", {

View file

@ -1,15 +1,13 @@
import { useEffect, useState } from "react";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { Collections } from "../../../schemas/pocketbase/schema";
import type { Event, Log, User } from "../../../schemas/pocketbase";
import type { Event, User, LimitedUser } from "../../../schemas/pocketbase";
import { Get } from "../../../scripts/pocketbase/Get";
import type { EventAttendee } from "../../../schemas/pocketbase";
import { Update } from "../../../scripts/pocketbase/Update";
// Extended User interface with points property
// Extended User interface with member_type property
interface ExtendedUser extends User {
points?: number;
member_type?: string;
}
@ -84,25 +82,13 @@ export function Stats() {
setEventsAttended(attendedEvents.totalItems);
// Get user points - either from the user record or calculate from attendees
// Calculate points from attendees
let totalPoints = 0;
// Calculate quarterly points
const quarterStartDate = getCurrentQuarterStartDate();
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
attendedEvents.items.forEach(attendee => {
const points = attendee.points_earned || 0;
@ -114,17 +100,26 @@ export function Stats() {
}
});
// Update the user record with calculated points if needed
if (currentUser) {
// Try to get the LimitedUser record to check if points match
try {
const update = Update.getInstance();
await update.updateFields(Collections.USERS, currentUser.id, {
points: totalPoints
});
} catch (error) {
console.error("Error updating user points:", error);
const limitedUserRecord = await get.getOne(
Collections.LIMITED_USERS,
userId
);
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);
@ -199,7 +194,7 @@ export function Stats() {
</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="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-desc flex flex-col items-start gap-1 mt-1">
<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 DisplaySettings from "./SettingsSection/DisplaySettings";
import ResumeSettings from "./SettingsSection/ResumeSettings";
import EmailRequestSettings from "./SettingsSection/EmailRequestSettings";
import ThemeToggle from "./universal/ThemeToggle";
// Import environment variables
@ -131,27 +130,6 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
</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 -->
<div
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 -->
<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">
<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"
>
<div class="badge badge-primary p-3">
<Icon name="heroicons:computer-desktop" class="h-5 w-5" />
</div>
Display Settings
@ -229,18 +205,14 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
<p class="text-sm opacity-70 mb-4">
Customize your dashboard appearance and display preferences
</p>
<div class="h-px w-full bg-border my-4"></div>
<div class="divider"></div>
<div
class="flex p-4 mb-4 text-sm rounded-lg bg-warning/20 text-warning-foreground"
role="alert"
>
<div class="alert alert-warning mb-4">
<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"
viewBox="0 0 24 24"
stroke="currentColor"
><path
stroke-linecap="round"
stroke-linejoin="round"

View file

@ -59,16 +59,7 @@ export default function AccountSecuritySettings({
checkAuth();
}, []);
const handleLogout = async () => {
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.');
}
};
// No logout functions needed here as logout is handled in the dashboard menu
const detectBrowser = (userAgent: string): string => {
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>
<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">
If you need to delete your account or have other account-related issues,
please contact an IEEE UCSD administrator.
</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>

View file

@ -258,17 +258,17 @@ export default function DisplaySettings() {
{/* Theme Settings */}
<div>
<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
value={theme}
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="dark">Dark</option>
</select>
<label className="mt-1 block">
<span className="text-xs text-muted-foreground">Select your preferred theme</span>
<label className="label">
<span className="label-text-alt">Select your preferred theme</span>
</label>
</div>
</div>
@ -276,19 +276,19 @@ export default function DisplaySettings() {
{/* Font Size Settings */}
<div>
<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
value={fontSize}
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="medium">Medium</option>
<option value="large">Large</option>
<option value="extra-large">Extra Large</option>
</select>
<label className="mt-1 block">
<span className="text-xs text-muted-foreground">Select your preferred font size</span>
<label className="label">
<span className="label-text-alt">Select your preferred font size</span>
</label>
</div>
</div>
@ -297,64 +297,54 @@ export default function DisplaySettings() {
<div>
<h4 className="font-semibold text-lg mb-2">Accessibility</h4>
<div className="flex items-center space-x-4 mb-4">
<label className="relative inline-flex items-center cursor-pointer">
<div className="form-control">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
checked={colorBlindMode}
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>
<span className="font-medium">Color Blind Mode</span>
<p className="text-xs text-muted-foreground">Enhances color contrast and uses color-blind friendly palettes</p>
<span className="label-text font-medium">Color Blind Mode</span>
<p className="text-xs opacity-70">Enhances color contrast and uses color-blind friendly palettes</p>
</div>
</label>
</div>
<div className="flex items-center space-x-4">
<label className="relative inline-flex items-center cursor-pointer">
<div className="form-control mt-2">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
checked={reducedMotion}
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>
<span className="font-medium">Reduced Motion</span>
<p className="text-xs text-muted-foreground">Minimizes animations and transitions</p>
<span className="label-text font-medium">Reduced Motion</span>
<p className="text-xs opacity-70">Minimizes animations and transitions</p>
</div>
</label>
</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.
</p>
<div className="mt-4">
<div className="form-control">
<div className="flex flex-col gap-2">
{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.
</p>
)}
<button
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}
>
{saving ? (
<>
<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'}
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>

View file

@ -370,7 +370,7 @@ export default function EmailRequestSettings() {
<div className="p-4 bg-base-200 rounded-lg">
<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>
</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 { motion, AnimatePresence } from 'framer-motion';
import type { ItemizedExpense } from '../../../schemas/pocketbase';
// import ZoomablePreview from '../universal/ZoomablePreview';
interface ReceiptFormData {
file: File;
@ -66,6 +67,35 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
const [locationAddress, setLocationAddress] = useState<string>('');
const [notes, setNotes] = 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>) => {
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 (
<motion.div
initial={{ opacity: 0 }}
@ -155,7 +248,11 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
variants={containerVariants}
initial="hidden"
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">
<AnimatePresence mode="wait">
@ -191,8 +288,9 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
</div>
</motion.div>
{/* Date */}
<motion.div variants={itemVariants} className="form-control">
{/* Date and Location in Grid */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Date</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)}
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>
{/* Location Name */}
<motion.div variants={itemVariants} className="form-control">
{/* Location Fields */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Location Name</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"
value={locationName}
onChange={(e) => setLocationName(e.target.value)}
placeholder="Store/vendor name"
required
/>
</motion.div>
</div>
{/* Location Address */}
<motion.div variants={itemVariants} className="form-control">
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Location Address</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"
value={locationAddress}
onChange={(e) => setLocationAddress(e.target.value)}
placeholder="Full address"
required
/>
</div>
</motion.div>
{/* Notes */}
{/* Notes - Reduced height */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Notes</span>
</label>
<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}
onChange={(e) => setNotes(e.target.value)}
rows={3}
rows={2}
placeholder="Additional notes..."
/>
</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 */}
<motion.div variants={itemVariants} className="space-y-4">
<div className="flex justify-between items-center">
<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>
<AnimatePresence>
@ -274,33 +476,48 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
exit={{ opacity: 0, x: 20 }}
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="grid gap-4">
<div className="card-body p-3">
<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">
<label className="label">
<span className="label-text">Description</span>
<label className="label py-1">
<span className="label-text text-xs">Description</span>
</label>
<input
type="text"
className="input input-bordered"
className="input input-bordered input-sm"
value={item.description}
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
placeholder="What was purchased?"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label">
<span className="label-text">Category</span>
<label className="label py-1">
<span className="label-text text-xs">Category</span>
</label>
<select
className="select select-bordered"
className="select select-bordered select-sm w-full"
value={item.category}
onChange={(e) => handleExpenseItemChange(index, 'category', e.target.value)}
required
>
<option value="">Select category</option>
<option value="">Select...</option>
{EXPENSE_CATEGORIES.map(category => (
<option key={category} value={category}>{category}</option>
))}
@ -308,29 +525,19 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Amount ($)</span>
<label className="label py-1">
<span className="label-text text-xs">Amount ($)</span>
</label>
<div className="flex items-center space-x-2">
<input
type="number"
className="input input-bordered"
value={item.amount}
className="input input-bordered input-sm w-full"
value={item.amount === 0 ? '' : item.amount}
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))}
min="0"
step="0.01"
placeholder="0.00"
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>
@ -338,38 +545,41 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
</motion.div>
))}
</AnimatePresence>
</motion.div>
{/* Tax */}
<motion.div variants={itemVariants} 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}
onChange={(e) => setTax(Number(e.target.value))}
min="0"
step="0.01"
/>
{/* Add Item Button - Moved to bottom */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex justify-center pt-2"
>
<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>
</motion.div>
</motion.div>
{/* Total */}
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<div className="space-y-2">
<div className="flex justify-between items-center text-base-content/70">
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-3 shadow-sm">
<div className="space-y-1">
<div className="flex justify-between items-center text-sm text-base-content/70">
<span>Subtotal:</span>
<span className="font-mono">${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)}</span>
</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 className="font-mono">${tax.toFixed(2)}</span>
</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 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>
</motion.div>
@ -412,13 +622,60 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
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
url={previewUrl}
filename={file?.name || ''}
isModal={false}
/>
{/* 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={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

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 { Authentication } from '../../../scripts/pocketbase/Authentication';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import { EmailClient } from '../../../scripts/email/EmailClient';
import ReceiptForm from './ReceiptForm';
import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion';
import FilePreview from '../universal/FilePreview';
import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase';
import type { ItemizedExpense, Reimbursement } from '../../../schemas/pocketbase';
interface ReceiptFormData {
file: File;
@ -277,11 +278,34 @@ export default function ReimbursementForm() {
formData.append('receipts', JSON.stringify(request.receipts));
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
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);
}, 500);
}
// Reset form
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) {
console.error('Error submitting reimbursement request:', error);
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 { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
@ -114,6 +114,27 @@ export default function ReimbursementList() {
useEffect(() => {
// console.log('Component mounted');
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
@ -156,7 +177,7 @@ export default function ReimbursementList() {
// Use DataSyncService to get data from IndexedDB with forced sync
const dataSync = DataSyncService.getInstance();
// Sync reimbursements collection
// Sync reimbursements collection with force sync
await dataSync.syncCollection(
Collections.REIMBURSEMENTS,
`submitted_by="${userId}"`,
@ -164,10 +185,10 @@ export default function ReimbursementList() {
'audit_notes'
);
// Get reimbursements from IndexedDB
// Get reimbursements from IndexedDB with forced sync to ensure latest data
const reimbursementRecords = await dataSync.getData<ReimbursementRequest>(
Collections.REIMBURSEMENTS,
false, // Don't force sync again
true, // Force sync to ensure we have the latest data
`submitted_by="${userId}"`,
'-created'
);

View file

@ -5,6 +5,7 @@ import { Get } from '../../../scripts/pocketbase/Get';
import { Update } from '../../../scripts/pocketbase/Update';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { FileManager } from '../../../scripts/pocketbase/FileManager';
import { EmailClient } from '../../../scripts/email/EmailClient';
import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion';
import type { Receipt as SchemaReceipt, User, Reimbursement } from '../../../schemas/pocketbase';
@ -32,6 +33,10 @@ interface FilterOptions {
dateRange: 'all' | 'week' | 'month' | 'year';
sortBy: 'date_of_purchase' | 'total_amount' | 'status';
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 {
@ -53,7 +58,11 @@ export default function ReimbursementManagementPortal() {
department: [],
dateRange: 'all',
sortBy: 'date_of_purchase',
sortOrder: 'desc'
sortOrder: 'desc',
hidePaid: true,
hideRejected: true,
compactView: false,
search: ''
});
const [auditNote, setAuditNote] = useState('');
const [loadingStatus, setLoadingStatus] = useState(false);
@ -110,6 +119,21 @@ export default function ReimbursementManagementPortal() {
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) {
const departmentFilter = filters.department.map(d => `department = "${d}"`).join(' || ');
filter = filter ? `${filter} && (${departmentFilter})` : `(${departmentFilter})`;
@ -160,11 +184,10 @@ export default function ReimbursementManagementPortal() {
submitter: userMap[record.submitted_by]
}));
setReimbursements(enrichedRecords);
// Load associated receipts
const receiptIds = enrichedRecords.flatMap(r => r.receipts || []);
let receiptMap: Record<string, ExtendedReceipt> = {};
if (receiptIds.length > 0) {
try {
const receiptRecords = await Promise.all(
@ -200,7 +223,7 @@ export default function ReimbursementManagementPortal() {
const validReceipts = receiptRecords.filter((r): r is ExtendedReceipt => r !== null);
const receiptMap = Object.fromEntries(
receiptMap = Object.fromEntries(
validReceipts.map(receipt => [receipt.id, receipt])
);
setReceipts(receiptMap);
@ -217,6 +240,52 @@ export default function ReimbursementManagementPortal() {
// console.log('No receipt IDs found in reimbursements');
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) {
console.error('Error loading reimbursements:', error);
toast.error('Failed to load reimbursements. Please try again later.');
@ -366,7 +435,7 @@ export default function ReimbursementManagementPortal() {
};
// 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 {
setLoadingStatus(true);
const update = Update.getInstance();
@ -375,15 +444,28 @@ export default function ReimbursementManagementPortal() {
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 });
// Add audit log for status change
await addAuditLog(id, 'status_change', {
from: selectedReimbursement?.status,
from: previousStatus,
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`);
}
await refreshAuditData(id);
} catch (error) {
console.error('Error updating status:', error);
@ -483,8 +565,7 @@ export default function ReimbursementManagementPortal() {
}
}));
setSelectedReceipt(receipt);
setShowReceiptModal(true);
// Don't show the receipt modal when auditing
toast.success('Receipt audited successfully');
} catch (error) {
console.error('Error auditing receipt:', error);
@ -582,6 +663,21 @@ export default function ReimbursementManagementPortal() {
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');
setAuditNote('');
setIsPrivateNote(true);
@ -613,8 +709,8 @@ export default function ReimbursementManagementPortal() {
try {
setLoadingStatus(true);
// First update the status
await updateStatus(rejectingId, 'rejected');
// First update the status (passing false to suppress the toast message)
await updateStatus(rejectingId, 'rejected', false);
// Then add the rejection reason as a public note
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">
Reimbursement Requests
</h2>
<div className="flex flex-wrap items-center gap-2">
<span className="badge badge-primary badge-md font-medium">
{reimbursements.length} Total
</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 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="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">
@ -754,6 +897,7 @@ export default function ReimbursementManagementPortal() {
</div>
</div>
{/* Department Filter */}
<div className="form-control">
<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">
@ -806,6 +950,7 @@ export default function ReimbursementManagementPortal() {
</div>
</div>
{/* Date Range Filter */}
<div className="form-control">
<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">
@ -824,7 +969,8 @@ export default function ReimbursementManagementPortal() {
</div>
</div>
<div className="form-control md:col-span-2">
{/* Sort Controls */}
<div className="form-control">
<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">
<Icon icon="heroicons:arrows-up-down" className="h-4 w-4" />
@ -850,6 +996,54 @@ export default function ReimbursementManagementPortal() {
</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>
{loading ? (
@ -873,7 +1067,7 @@ export default function ReimbursementManagementPortal() {
</motion.div>
) : (
<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) => (
<motion.div
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'}`}
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="flex justify-between items-start gap-4">
<div className="space-y-2 flex-1 min-w-0">
@ -925,6 +1147,7 @@ export default function ReimbursementManagementPortal() {
</div>
</div>
</div>
)}
</motion.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 [imgSrc, setImgSrc] = useState<string>(url);
const [isObjectUrl, setIsObjectUrl] = useState<boolean>(false);
const [errorCount, setErrorCount] = useState<number>(0);
const maxRetries = 2;
// Clean up object URL when component unmounts
useEffect(() => {
@ -24,13 +26,51 @@ const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) =
};
}, [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 () => {
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 {
// 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
// console.log('Trying to fetch image as blob:', url);
const response = await fetch(url, { mode: 'cors' });
const response = await fetch(url, {
mode: 'cors',
cache: 'no-cache' // Avoid caching issues
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@ -38,27 +78,24 @@ const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) =
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
// console.log('Created object URL:', objectUrl);
// Update the image source with the object URL
setImgSrc(objectUrl);
setIsObjectUrl(true);
} catch (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
// console.log('Image URL that failed:', url);
// console.log('Current auth status:',
// Authentication.getInstance().isAuthenticated() ? 'Authenticated' : 'Not authenticated'
// );
// Only show error to user on final retry
if (errorCount >= maxRetries - 1) {
onError('Failed to load image. This might be due to permission issues or the file may not exist.');
}
}
};
return (
<img
src={imgSrc}
alt={filename}
alt={filename || 'Image preview'}
className="max-w-full h-auto rounded-lg"
loading="lazy"
onError={handleError}
@ -167,6 +204,22 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
setState(prev => ({ ...prev, loading: true, error: null }));
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
if (state.url.endsWith('.pdf')) {
setState(prev => ({
@ -175,12 +228,377 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
fileType: 'application/pdf',
loading: false
}));
// Cache the result
contentCache.set(cacheKey, {
content: 'pdf',
fileType: 'application/pdf',
timestamp: Date.now()
});
loadingRef.current = false;
return;
}
// Rest of your existing loadContent logic
// ... existing content loading code ...
// Handle image files
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) {
console.error('Error loading content:', err);
setState(prev => ({
@ -193,8 +611,20 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
}, [state.url]);
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();
}, 50);
return () => clearTimeout(timer);
}, [state.url, state.isVisible, isModal, loadContent]);
// Intersection observer effect
@ -364,7 +794,14 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
// Update the Try Again button handler
const handleTryAgain = useCallback(() => {
loadingRef.current = false; // Reset loading ref
setState(prev => ({
...prev,
error: null,
loading: true
}));
setTimeout(() => {
loadContent();
}, 100); // Small delay to ensure state is updated
}, [loadContent]);
// If URL is empty, show a message
@ -399,7 +836,8 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
</div>
<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">
<span className="loading loading-spinner loading-lg"></span>
</div>
@ -448,22 +886,39 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
{!state.loading && !state.error && state.content === 'video' && (
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
<div className="w-full max-w-2xl">
<video
controls
className="max-w-full h-auto rounded-lg"
preload="metadata"
src={state.url}
onError={(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 => ({
...prev,
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.
</video>
</div>
</div>
)}
{!state.loading && !state.error && state.content === 'pdf' && (
@ -522,6 +977,41 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
</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) && (
<div className="overflow-x-auto max-h-[600px] bg-base-200">
<div className={`p-1 ${state.filename.toLowerCase().endsWith('.csv') ? 'p-4' : ''}`}>

View file

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

View file

@ -84,20 +84,15 @@ export default function ThemeToggle() {
};
return (
<div className="relative">
<div className="dropdown dropdown-end">
<button
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`}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme (Light mode is experimental)`}
disabled={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>
) : (
{!isLoading && (
theme === 'light' ? (
<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" />
@ -109,9 +104,9 @@ export default function ThemeToggle() {
)
)}
</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="p-3 text-xs">
<p className="font-bold text-amber-600 dark:text-amber-400 mb-1">Warning:</p>
<div className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52 mt-2 text-xs">
<div className="p-2">
<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>
</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
</p>
</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">
<FaDiscord />
</div>

View file

@ -36,6 +36,13 @@ sections:
component: "Officer_EventManagement"
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:
title: "Reimbursement Management"
icon: "heroicons:credit-card"
@ -50,6 +57,13 @@ sections:
component: "Officer_EventRequestManagement"
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:
title: "Event Request Form"
icon: "heroicons:document-text"
@ -58,19 +72,19 @@ sections:
class: "text-info hover:text-info-focus"
# Sponsor Menu
sponsorDashboard:
title: "Sponsor Dashboard"
icon: "heroicons:briefcase"
role: "sponsor"
component: "SponsorDashboard"
class: "text-warning hover:text-warning-focus"
sponsorAnalytics:
title: "Analytics"
title: "Event Analytics"
icon: "heroicons:chart-bar"
role: "sponsor"
component: "SponsorAnalytics"
class: "text-warning hover:text-warning-focus"
component: "SponsorAnalyticsSection"
class: "text-primary hover:text-primary-focus"
resumeDatabase:
title: "Resume Database"
icon: "heroicons:document-text"
role: "sponsor"
component: "ResumeDatabase"
class: "text-secondary hover:text-secondary-focus"
# Administrator Menu
adminDashboard:
@ -103,12 +117,13 @@ categories:
officer:
title: "Officer Menu"
sections: ["eventManagement", "eventRequestForm"]
sections: ["eventManagement", "officerEmailManagement", "eventRequestForm"]
role: "general"
executive:
title: "Executive Menu"
sections: ["reimbursementManagement", "eventRequestManagement"]
sections:
["reimbursementManagement", "eventRequestManagement", "officerManagement"]
role: "executive"
admin:
@ -118,10 +133,10 @@ categories:
sponsor:
title: "Sponsor Portal"
sections: ["sponsorDashboard", "sponsorAnalytics"]
sections: ["sponsorAnalytics", "resumeDatabase"]
role: "sponsor"
account:
title: "Account"
sections: ["settings", "logout"]
sections: ["settings"]
role: "none"

View file

@ -2,7 +2,7 @@
{
"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.",
"link": "/quarterly",
"link": "/projects/quarterly",
"number": "01",
"delay": "100"
},

View file

@ -2,11 +2,10 @@
import Navbar from "../components/core/Navbar.astro";
import Footer from "../components/core/Footer.astro";
import InView from "../components/core/InView.astro";
import { initTheme } from "../scripts/database/initTheme";
---
<!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>
<meta charset="UTF-8" />
<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"
></script>
<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");
} else {
// Apply saved theme
const savedTheme = localStorage.getItem("theme");
document.documentElement.setAttribute("data-theme", savedTheme);
}
</script>
</head>
<InView />
<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">
<header class="sticky top-0 w-full z-[999]">
<Navbar />

View file

@ -292,7 +292,7 @@ async function sendCredentialsEmail(
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,
IEEE UCSD Web Team
@ -311,7 +311,7 @@ async function sendWebmasterNotification(
) {
// In a real implementation, you would use an email service
console.log(`
To: webmaster@ieeeucsd.org
To: webmaster@ieeeatucsd.org
Subject: New IEEE Email Account 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 { OfficerTypes } from "../schemas/pocketbase/schema";
import { initAuthSync } from "../scripts/database/initAuthSync";
import { initTheme } from "../scripts/database/initTheme";
import ToastProvider from "../components/dashboard/universal/ToastProvider";
import FirstTimeLoginManager from "../components/dashboard/universal/FirstTimeLoginManager";
import ThemeToggle from "../components/dashboard/universal/ThemeToggle";
const title = "Dashboard";
@ -42,7 +40,7 @@ const components = Object.fromEntries(
---
<!doctype html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -51,10 +49,6 @@ const components = Object.fromEntries(
<script
src="https://code.iconify.design/iconify-icon/2.3.0/iconify-icon.min.js"
></script>
<script is:inline>
// Set a default theme until IndexedDB loads
document.documentElement.setAttribute("data-theme", "dark");
</script>
</head>
<body class="bg-base-200">
<!-- First Time Login Manager - This handles the onboarding popup for new users -->
@ -157,8 +151,12 @@ const components = Object.fromEntries(
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto scrollbar-hide py-6">
<ul class="menu gap-2 px-4 text-base-content/80">
<nav
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 -->
<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>
</ul>
</nav>
@ -393,7 +405,6 @@ const components = Object.fromEntries(
import { hasAccess, type OfficerStatus } from "../utils/roleAccess";
import { OfficerTypes } from "../schemas/pocketbase/schema";
import { initAuthSync } from "../scripts/database/initAuthSync";
import { initTheme } from "../scripts/database/initTheme";
const auth = Authentication.getInstance();
const get = Get.getInstance();
@ -404,11 +415,6 @@ const components = Object.fromEntries(
window.toast = () => {};
}
// Initialize theme from IndexedDB
initTheme().catch((err) =>
console.error("Error initializing theme:", err)
);
// Initialize page state
const 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
const handleNavigation = () => {
const navButtons =
@ -488,8 +668,7 @@ const components = Object.fromEntries(
// Handle logout button
if (sectionKey === "logout") {
auth.logout();
window.location.reload();
showLogoutConfirmation();
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
if (sidebar) {
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...";
---
<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">
<p class="text-2xl font-medium">Redirecting to dashboard...</p>
<div class="mt-4">
<div class="loading loading-spinner loading-lg"></div>
</div>
</div>
</main>
<script>
</main>
<script>
import { RedirectHandler } from "../scripts/auth/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;
zelle_information?: 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
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)
@ -41,6 +41,18 @@ export interface User extends BaseRecord {
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
* Represents events created in the system
@ -56,6 +68,7 @@ export interface Event extends BaseRecord {
start_date: string;
end_date: string;
published: boolean;
event_type: string; // social, technical, outreach, professional, projects, other
has_food: boolean;
}
@ -99,13 +112,14 @@ export interface EventRequest extends BaseRecord {
other_flyer_type?: string;
flyer_advertising_start_date?: string;
flyer_additional_requests?: string;
flyers_completed?: boolean; // Track if flyers have been completed by PR team
photography_needed: boolean;
required_logos?: string[]; // IEEE, AS, HKN, TESC, PIB, TNT, SWE, OTHER
other_logos?: string[]; // Array of logo IDs
advertising_format?: string;
will_or_have_room_booking?: boolean;
expected_attendance?: number;
room_booking?: string; // signle file
room_booking_files?: string[]; // CHANGED: Multiple files instead of single file
as_funding_required: boolean;
food_drinks_being_served: boolean;
itemized_invoice?: string; // JSON string
@ -114,6 +128,7 @@ export interface EventRequest extends BaseRecord {
needs_graphics?: boolean;
needs_as_funding?: boolean;
status: "submitted" | "pending" | "completed" | "declined";
declined_reason?: string; // Reason for declining the event request
requested_user?: string;
}
@ -217,6 +232,7 @@ export const Collections = {
REIMBURSEMENTS: "reimbursement",
RECEIPTS: "receipts",
SPONSORS: "sponsors",
LIMITED_USERS: "limitedUser",
};
/**

View file

@ -106,6 +106,7 @@ export class DataSyncService {
filter: string = "",
sort: string = "-created",
expand: Record<string, any> | string[] | string = {},
detectDeletions: boolean = true,
): Promise<T[]> {
// Skip in non-browser environments
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, {
expand: normalizedExpand,
});
@ -169,12 +170,15 @@ export class DataSyncService {
return [];
}
// Get existing items to handle conflicts
// Get existing items to handle conflicts and deletions
const existingItems = await table.toArray();
const existingItemsMap = new Map(
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
const itemsToStore = await Promise.all(
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);
// Update last sync timestamp
@ -448,6 +488,7 @@ export class DataSyncService {
filter: string = "",
sort: string = "-created",
expand: Record<string, any> | string[] | string = {},
detectDeletions: boolean = true,
): Promise<T[]> {
const db = this.dexieService.getDB();
const table = this.getTableForCollection(collection);
@ -464,7 +505,7 @@ export class DataSyncService {
if (!this.offlineMode && (forceSync || now - lastSync > syncThreshold)) {
try {
await this.syncCollection<T>(collection, filter, sort, expand);
await this.syncCollection<T>(collection, filter, sort, expand, detectDeletions);
} catch (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))",
},
},
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: [
require("tailwindcss-motion"),