Compare commits
4 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a86140a6b5 | ||
![]() |
e27a78a15b | ||
![]() |
c4278b7773 | ||
![]() |
d055c5ee69 |
80 changed files with 2189 additions and 13987 deletions
|
@ -1 +0,0 @@
|
|||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -5,8 +5,6 @@ dist/
|
|||
.astro/
|
||||
.cursor
|
||||
|
||||
final_review_gate.py
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
|
|
55
Dockerfile
55
Dockerfile
|
@ -1,55 +0,0 @@
|
|||
# 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"]
|
|
@ -1,12 +0,0 @@
|
|||
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
|
|
@ -1,4 +1,4 @@
|
|||
[phases.setup]
|
||||
nixPkgs = ["nodejs_20", "bun"]
|
||||
nixPkgs = ["nodejs_18", "bun"]
|
||||
aptPkgs = ["curl", "wget"]
|
||||
|
||||
|
|
28
package.json
28
package.json
|
@ -2,9 +2,6 @@
|
|||
"name": "ieeeucsd-dev",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"engines": {
|
||||
"node": ">=18.20.8"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "node ./dist/server/entry.mjs",
|
||||
|
@ -13,11 +10,10 @@
|
|||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@astrojs/mdx": "4.0.3",
|
||||
"@astrojs/node": "^9.0.0",
|
||||
"@astrojs/react": "^4.2.0",
|
||||
"@astrojs/tailwind": "5.1.4",
|
||||
"@heroui/react": "^2.7.5",
|
||||
"@iconify-json/heroicons": "^1.2.2",
|
||||
"@iconify-json/mdi": "^1.2.3",
|
||||
|
@ -25,10 +21,9 @@
|
|||
"@types/highlight.js": "^10.1.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/puppeteer": "^7.0.4",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.1",
|
||||
"astro": "^5.5.6",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"astro": "5.1.1",
|
||||
"astro-expressive-code": "^0.40.2",
|
||||
"astro-icon": "^1.1.5",
|
||||
"chart.js": "^4.4.7",
|
||||
|
@ -42,16 +37,13 @@
|
|||
"next": "^15.1.2",
|
||||
"pocketbase": "^0.25.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"puppeteer": "^24.10.1",
|
||||
"react": "^19.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-icons": "^5.4.0",
|
||||
"rehype-expressive-code": "^0.40.2",
|
||||
"resend": "^4.5.1",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.8.3"
|
||||
"tailwindcss": "^3.4.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/prismjs": "^1.26.5",
|
||||
|
|
|
@ -5,144 +5,151 @@ import EventLoad from "./EventsSection/EventLoad";
|
|||
---
|
||||
|
||||
<div id="" class="">
|
||||
<div class="mb-4 sm:mb-6 px-4 sm:px-6">
|
||||
<h2 class="text-xl sm:text-2xl font-bold">Events</h2>
|
||||
<p class="opacity-70 text-sm sm:text-base">
|
||||
View and manage your IEEE UCSD events
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 mb-4 sm:mb-6 px-4 sm:px-6"
|
||||
>
|
||||
<!-- Event Check-in Card -->
|
||||
<div class="w-full">
|
||||
<EventCheckIn client:load />
|
||||
<div class="mb-4 sm:mb-6 px-4 sm:px-6">
|
||||
<h2 class="text-xl sm:text-2xl font-bold">Events</h2>
|
||||
<p class="opacity-70 text-sm sm:text-base">
|
||||
View and manage your IEEE UCSD events
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Event Registration Card -->
|
||||
<div class="w-full">
|
||||
<div
|
||||
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-base-100 opacity-0 group-hover:opacity-90 transition-opacity duration-300 flex items-center justify-center z-10"
|
||||
>
|
||||
<span class="text-base-content font-medium text-sm sm:text-base"
|
||||
>Coming Soon</span
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 mb-4 sm:mb-6 px-4 sm:px-6"
|
||||
>
|
||||
<!-- Event Check-in Card -->
|
||||
<div class="w-full">
|
||||
<EventCheckIn client:load />
|
||||
</div>
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h3 class="card-title text-base sm:text-lg mb-3 sm:mb-4">
|
||||
Event Registration
|
||||
</h3>
|
||||
<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="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>Social Event - Game Night</option>
|
||||
</select>
|
||||
<button
|
||||
class="btn btn-primary text-sm sm:text-base h-10 min-h-[2.5rem] w-full sm:w-[100px]"
|
||||
disabled>Register</button
|
||||
>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-card 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"
|
||||
>Coming Soon</span
|
||||
>
|
||||
</div>
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<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"
|
||||
>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]"
|
||||
disabled
|
||||
>
|
||||
<option disabled selected>Pick an event</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"
|
||||
disabled>Register</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EventLoad client:load />
|
||||
<EventLoad client:load />
|
||||
</div>
|
||||
|
||||
<!-- Event Details Modal -->
|
||||
<dialog id="eventDetailsModal" class="modal">
|
||||
<div class="modal-box max-w-[90vw] sm:max-w-4xl p-4 sm:p-6">
|
||||
<div class="flex justify-between items-center mb-3 sm:mb-4">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<h3 class="font-bold text-base sm:text-lg" id="modalTitle">
|
||||
Event Files
|
||||
</h3>
|
||||
<button
|
||||
id="downloadAllBtn"
|
||||
class="btn btn-primary btn-sm gap-1 text-xs sm:text-sm"
|
||||
onclick="window.downloadAllFiles()"
|
||||
>
|
||||
<iconify-icon
|
||||
icon="heroicons:arrow-down-tray-20-solid"
|
||||
className="h-3 w-3 sm:h-4 sm:w-4"></iconify-icon>
|
||||
Download All
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-box max-w-[90vw] sm:max-w-4xl p-4 sm:p-6">
|
||||
<div class="flex justify-between items-center mb-3 sm:mb-4">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<h3 class="font-bold text-base sm:text-lg" id="modalTitle">
|
||||
Event Files
|
||||
</h3>
|
||||
<button
|
||||
id="downloadAllBtn"
|
||||
class="btn btn-primary btn-sm gap-1 text-xs sm:text-sm"
|
||||
onclick="window.downloadAllFiles()"
|
||||
>
|
||||
<iconify-icon
|
||||
icon="heroicons:arrow-down-tray-20-solid"
|
||||
className="h-3 w-3 sm:h-4 sm:w-4"></iconify-icon>
|
||||
Download All
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="filesContent" class="space-y-3 sm:space-y-4">
|
||||
<!-- Files list will be populated here -->
|
||||
<div id="filesContent" class="space-y-3 sm:space-y-4">
|
||||
<!-- Files list will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button onclick="window.closeEventDetailsModal()">close</button>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button onclick="window.closeEventDetailsModal()">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Universal File Preview Modal -->
|
||||
<dialog id="filePreviewModal" class="modal">
|
||||
<div class="modal-box max-w-[90vw] sm:max-w-4xl p-4 sm:p-6">
|
||||
<div class="flex justify-between items-center mb-3 sm:mb-4">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm text-xs sm:text-sm"
|
||||
onclick="window.closeFilePreviewEvents()">Close</button
|
||||
>
|
||||
<h3
|
||||
class="font-bold text-base sm:text-lg truncate"
|
||||
id="previewFileName"
|
||||
>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="modal-box max-w-[90vw] sm:max-w-4xl p-4 sm:p-6">
|
||||
<div class="flex justify-between items-center mb-3 sm:mb-4">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm text-xs sm:text-sm"
|
||||
onclick="window.closeFilePreviewEvents()">Close</button
|
||||
>
|
||||
<h3
|
||||
class="font-bold text-base sm:text-lg truncate"
|
||||
id="previewFileName"
|
||||
>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative" id="previewContainer">
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
<div id="previewContent" class="w-full">
|
||||
<FilePreview client:load isModal={true} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative" id="previewContainer">
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
<div id="previewContent" class="w-full">
|
||||
<FilePreview client:load isModal={true} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button onclick="window.closeFilePreviewEvents()">close</button>
|
||||
</form>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button onclick="window.closeFilePreviewEvents()">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
import { toast } from "react-hot-toast";
|
||||
import JSZip from "jszip";
|
||||
import { toast } from "react-hot-toast";
|
||||
import JSZip from "jszip";
|
||||
|
||||
// Add styles to the document
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
// Add styles to the document
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
/* Custom styles for the event details modal */
|
||||
.event-details-grid {
|
||||
display: grid;
|
||||
|
@ -158,227 +165,234 @@ import EventLoad from "./EventsSection/EventLoad";
|
|||
|
||||
/* Remove custom toast styles since we're using react-hot-toast */
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Add helper functions for file preview
|
||||
function getFileType(filename: string): string {
|
||||
const extension = filename.split(".").pop()?.toLowerCase();
|
||||
const mimeTypes: { [key: string]: string } = {
|
||||
pdf: "application/pdf",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
mp4: "video/mp4",
|
||||
mp3: "audio/mpeg",
|
||||
txt: "text/plain",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
xls: "application/vnd.ms-excel",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
json: "application/json",
|
||||
// Add helper functions for file preview
|
||||
function getFileType(filename: string): string {
|
||||
const extension = filename.split(".").pop()?.toLowerCase();
|
||||
const mimeTypes: { [key: string]: string } = {
|
||||
pdf: "application/pdf",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
mp4: "video/mp4",
|
||||
mp3: "audio/mpeg",
|
||||
txt: "text/plain",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
xls: "application/vnd.ms-excel",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
json: "application/json",
|
||||
};
|
||||
|
||||
return mimeTypes[extension || ""] || "application/octet-stream";
|
||||
}
|
||||
|
||||
// Universal file preview function for events section
|
||||
window.previewFileEvents = function (url: string, filename: string) {
|
||||
// console.log("previewFileEvents called with:", { url, filename });
|
||||
// console.log("URL type:", typeof url, "URL length:", url?.length || 0);
|
||||
// console.log(
|
||||
// "Filename type:",
|
||||
// typeof filename,
|
||||
// "Filename length:",
|
||||
// filename?.length || 0
|
||||
// );
|
||||
|
||||
// Validate inputs
|
||||
if (!url || typeof url !== "string") {
|
||||
console.error("Invalid URL provided to previewFileEvents:", url);
|
||||
toast.error("Cannot preview file: Invalid URL");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filename || typeof filename !== "string") {
|
||||
console.error(
|
||||
"Invalid filename provided to previewFileEvents:",
|
||||
filename
|
||||
);
|
||||
toast.error("Cannot preview file: Invalid filename");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure URL is properly formatted
|
||||
if (!url.startsWith("http")) {
|
||||
console.warn(
|
||||
"URL doesn't start with http, attempting to fix:",
|
||||
url
|
||||
);
|
||||
if (url.startsWith("/")) {
|
||||
url = `https://pocketbase.ieeeucsd.org${url}`;
|
||||
} else {
|
||||
url = `https://pocketbase.ieeeucsd.org/${url}`;
|
||||
}
|
||||
// console.log("Fixed URL:", url);
|
||||
}
|
||||
|
||||
const modal = document.getElementById(
|
||||
"filePreviewModal"
|
||||
) as HTMLDialogElement;
|
||||
const previewFileName = document.getElementById("previewFileName");
|
||||
const previewContent = document.getElementById("previewContent");
|
||||
const loadingSpinner = document.getElementById("previewLoadingSpinner");
|
||||
|
||||
if (modal && previewFileName && previewContent) {
|
||||
// console.log("Found all required elements");
|
||||
|
||||
// Show loading spinner
|
||||
if (loadingSpinner) {
|
||||
loadingSpinner.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Update the filename display
|
||||
previewFileName.textContent = filename;
|
||||
|
||||
// Show the modal
|
||||
modal.showModal();
|
||||
|
||||
// Test the URL with a fetch before dispatching the event
|
||||
fetch(url, { method: "HEAD" })
|
||||
.then((response) => {
|
||||
// console.log(
|
||||
// "URL test response:",
|
||||
// response.status,
|
||||
// response.ok
|
||||
// );
|
||||
if (!response.ok) {
|
||||
console.warn("URL might not be accessible:", url);
|
||||
toast(
|
||||
"File might not be accessible. Attempting to preview anyway.",
|
||||
{
|
||||
icon: "⚠️",
|
||||
style: {
|
||||
borderRadius: "10px",
|
||||
background: "#FFC107",
|
||||
color: "#000",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error testing URL:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
// Dispatch state change event to update the FilePreview component
|
||||
// console.log(
|
||||
// "Dispatching filePreviewStateChange event with:",
|
||||
// { url, filename }
|
||||
// );
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("filePreviewStateChange", {
|
||||
detail: { url, filename },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Hide loading spinner after a short delay
|
||||
setTimeout(() => {
|
||||
if (loadingSpinner) {
|
||||
loadingSpinner.classList.add("hidden");
|
||||
}
|
||||
}, 1000); // Increased delay to allow for URL testing
|
||||
} else {
|
||||
console.error("Missing required elements for file preview");
|
||||
toast.error("Could not initialize file preview");
|
||||
}
|
||||
};
|
||||
|
||||
return mimeTypes[extension || ""] || "application/octet-stream";
|
||||
}
|
||||
// Close file preview for events section
|
||||
window.closeFilePreviewEvents = function () {
|
||||
// console.log("closeFilePreviewEvents called");
|
||||
const modal = document.getElementById(
|
||||
"filePreviewModal"
|
||||
) as HTMLDialogElement;
|
||||
const previewFileName = document.getElementById("previewFileName");
|
||||
const previewContent = document.getElementById("previewContent");
|
||||
const loadingSpinner = document.getElementById("previewLoadingSpinner");
|
||||
|
||||
// Universal file preview function for events section
|
||||
window.previewFileEvents = function (url: string, filename: string) {
|
||||
// console.log("previewFileEvents called with:", { url, filename });
|
||||
// console.log("URL type:", typeof url, "URL length:", url?.length || 0);
|
||||
// console.log(
|
||||
// "Filename type:",
|
||||
// typeof filename,
|
||||
// "Filename length:",
|
||||
// filename?.length || 0
|
||||
// );
|
||||
if (loadingSpinner) {
|
||||
loadingSpinner.classList.add("hidden");
|
||||
}
|
||||
|
||||
// Validate inputs
|
||||
if (!url || typeof url !== "string") {
|
||||
console.error("Invalid URL provided to previewFileEvents:", url);
|
||||
toast.error("Cannot preview file: Invalid URL");
|
||||
return;
|
||||
}
|
||||
if (modal && previewFileName && previewContent) {
|
||||
// console.log("Resetting preview and closing modal");
|
||||
|
||||
if (!filename || typeof filename !== "string") {
|
||||
console.error(
|
||||
"Invalid filename provided to previewFileEvents:",
|
||||
filename,
|
||||
);
|
||||
toast.error("Cannot preview file: Invalid filename");
|
||||
return;
|
||||
}
|
||||
// First reset the preview state by dispatching an event with empty values
|
||||
// This ensures the FilePreview component clears its internal state
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("filePreviewStateChange", {
|
||||
detail: { url: "", filename: "" },
|
||||
})
|
||||
);
|
||||
|
||||
// Ensure URL is properly formatted
|
||||
if (!url.startsWith("http")) {
|
||||
console.warn("URL doesn't start with http, attempting to fix:", url);
|
||||
if (url.startsWith("/")) {
|
||||
url = `https://pocketbase.ieeeucsd.org${url}`;
|
||||
} else {
|
||||
url = `https://pocketbase.ieeeucsd.org/${url}`;
|
||||
}
|
||||
// console.log("Fixed URL:", url);
|
||||
}
|
||||
// Reset the UI
|
||||
previewFileName.textContent = "";
|
||||
|
||||
const modal = document.getElementById(
|
||||
"filePreviewModal",
|
||||
) as HTMLDialogElement;
|
||||
const previewFileName = document.getElementById("previewFileName");
|
||||
const previewContent = document.getElementById("previewContent");
|
||||
const loadingSpinner = document.getElementById("previewLoadingSpinner");
|
||||
// Close the modal
|
||||
modal.close();
|
||||
|
||||
if (modal && previewFileName && previewContent) {
|
||||
// console.log("Found all required elements");
|
||||
// console.log("File preview modal closed and state reset");
|
||||
} else {
|
||||
console.error("Could not find elements to close file preview");
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading spinner
|
||||
if (loadingSpinner) {
|
||||
loadingSpinner.classList.remove("hidden");
|
||||
}
|
||||
// Update the showFilePreview function for events section
|
||||
window.showFilePreviewEvents = function (file: {
|
||||
url: string;
|
||||
name: string;
|
||||
}) {
|
||||
// console.log("showFilePreviewEvents called with:", file);
|
||||
if (!file || !file.url || !file.name) {
|
||||
console.error("Invalid file data:", file);
|
||||
toast.error("Could not preview file: missing file information");
|
||||
return;
|
||||
}
|
||||
window.previewFileEvents(file.url, file.name);
|
||||
};
|
||||
|
||||
// Update the filename display
|
||||
previewFileName.textContent = filename;
|
||||
// Update the openDetailsModal function to use the events-specific preview
|
||||
window.openDetailsModal = function (event: any) {
|
||||
const modal = document.getElementById(
|
||||
"eventDetailsModal"
|
||||
) as HTMLDialogElement;
|
||||
const filesContent = document.getElementById(
|
||||
"filesContent"
|
||||
) as HTMLDivElement;
|
||||
|
||||
// Show the modal
|
||||
modal.showModal();
|
||||
// Check if event has ended
|
||||
const eventEndDate = new Date(event.end_date);
|
||||
const now = new Date();
|
||||
|
||||
// Test the URL with a fetch before dispatching the event
|
||||
fetch(url, { method: "HEAD" })
|
||||
.then((response) => {
|
||||
// console.log(
|
||||
// "URL test response:",
|
||||
// response.status,
|
||||
// response.ok
|
||||
// );
|
||||
if (!response.ok) {
|
||||
console.warn("URL might not be accessible:", url);
|
||||
toast(
|
||||
"File might not be accessible. Attempting to preview anyway.",
|
||||
{
|
||||
if (eventEndDate > now) {
|
||||
toast("Files are only available after the event has ended.", {
|
||||
icon: "⚠️",
|
||||
style: {
|
||||
borderRadius: "10px",
|
||||
background: "#FFC107",
|
||||
color: "#000",
|
||||
borderRadius: "10px",
|
||||
background: "#FFC107",
|
||||
color: "#000",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error testing URL:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
// Dispatch state change event to update the FilePreview component
|
||||
// console.log(
|
||||
// "Dispatching filePreviewStateChange event with:",
|
||||
// { url, filename }
|
||||
// );
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("filePreviewStateChange", {
|
||||
detail: { url, filename },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Hide loading spinner after a short delay
|
||||
setTimeout(() => {
|
||||
if (loadingSpinner) {
|
||||
loadingSpinner.classList.add("hidden");
|
||||
});
|
||||
return;
|
||||
}
|
||||
}, 1000); // Increased delay to allow for URL testing
|
||||
} else {
|
||||
console.error("Missing required elements for file preview");
|
||||
toast.error("Could not initialize file preview");
|
||||
}
|
||||
};
|
||||
|
||||
// Close file preview for events section
|
||||
window.closeFilePreviewEvents = function () {
|
||||
// console.log("closeFilePreviewEvents called");
|
||||
const modal = document.getElementById(
|
||||
"filePreviewModal",
|
||||
) as HTMLDialogElement;
|
||||
const previewFileName = document.getElementById("previewFileName");
|
||||
const previewContent = document.getElementById("previewContent");
|
||||
const loadingSpinner = document.getElementById("previewLoadingSpinner");
|
||||
// Reset state
|
||||
window.currentEventId = event.id;
|
||||
if (filesContent) filesContent.classList.remove("hidden");
|
||||
|
||||
if (loadingSpinner) {
|
||||
loadingSpinner.classList.add("hidden");
|
||||
}
|
||||
// Populate files content
|
||||
if (
|
||||
event.files &&
|
||||
Array.isArray(event.files) &&
|
||||
event.files.length > 0
|
||||
) {
|
||||
const baseUrl = "https://pocketbase.ieeeucsd.org";
|
||||
const collectionId = "events";
|
||||
const recordId = event.id;
|
||||
|
||||
if (modal && previewFileName && previewContent) {
|
||||
// console.log("Resetting preview and closing modal");
|
||||
|
||||
// First reset the preview state by dispatching an event with empty values
|
||||
// This ensures the FilePreview component clears its internal state
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("filePreviewStateChange", {
|
||||
detail: { url: "", filename: "" },
|
||||
}),
|
||||
);
|
||||
|
||||
// Reset the UI
|
||||
previewFileName.textContent = "";
|
||||
|
||||
// Close the modal
|
||||
modal.close();
|
||||
|
||||
// console.log("File preview modal closed and state reset");
|
||||
} else {
|
||||
console.error("Could not find elements to close file preview");
|
||||
}
|
||||
};
|
||||
|
||||
// Update the showFilePreview function for events section
|
||||
window.showFilePreviewEvents = function (file: {
|
||||
url: string;
|
||||
name: string;
|
||||
}) {
|
||||
// console.log("showFilePreviewEvents called with:", file);
|
||||
if (!file || !file.url || !file.name) {
|
||||
console.error("Invalid file data:", file);
|
||||
toast.error("Could not preview file: missing file information");
|
||||
return;
|
||||
}
|
||||
window.previewFileEvents(file.url, file.name);
|
||||
};
|
||||
|
||||
// Update the openDetailsModal function to use the events-specific preview
|
||||
window.openDetailsModal = function (event: any) {
|
||||
const modal = document.getElementById(
|
||||
"eventDetailsModal",
|
||||
) as HTMLDialogElement;
|
||||
const filesContent = document.getElementById(
|
||||
"filesContent",
|
||||
) as HTMLDivElement;
|
||||
|
||||
// Check if event has ended
|
||||
const eventEndDate = new Date(event.end_date);
|
||||
const now = new Date();
|
||||
|
||||
if (eventEndDate > now) {
|
||||
toast("Files are only available after the event has ended.", {
|
||||
icon: "⚠️",
|
||||
style: {
|
||||
borderRadius: "10px",
|
||||
background: "#FFC107",
|
||||
color: "#000",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset state
|
||||
window.currentEventId = event.id;
|
||||
if (filesContent) filesContent.classList.remove("hidden");
|
||||
|
||||
// Populate files content
|
||||
if (event.files && Array.isArray(event.files) && event.files.length > 0) {
|
||||
const baseUrl = "https://pocketbase.ieeeucsd.org";
|
||||
const collectionId = "events";
|
||||
const recordId = event.id;
|
||||
|
||||
filesContent.innerHTML = `
|
||||
filesContent.innerHTML = `
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
|
@ -389,16 +403,16 @@ import EventLoad from "./EventsSection/EventLoad";
|
|||
</thead>
|
||||
<tbody>
|
||||
${event.files
|
||||
.map((file: string) => {
|
||||
// Ensure the file URL is properly formatted
|
||||
const fileUrl = `${baseUrl}/api/files/${collectionId}/${recordId}/${file}`;
|
||||
const fileType = getFileType(file);
|
||||
// Properly escape the data for the onclick handler
|
||||
const fileData = {
|
||||
url: fileUrl,
|
||||
name: file,
|
||||
};
|
||||
return `
|
||||
.map((file: string) => {
|
||||
// Ensure the file URL is properly formatted
|
||||
const fileUrl = `${baseUrl}/api/files/${collectionId}/${recordId}/${file}`;
|
||||
const fileType = getFileType(file);
|
||||
// Properly escape the data for the onclick handler
|
||||
const fileData = {
|
||||
url: fileUrl,
|
||||
name: file,
|
||||
};
|
||||
return `
|
||||
<tr>
|
||||
<td>${file}</td>
|
||||
<td class="text-right">
|
||||
|
@ -411,123 +425,123 @@ import EventLoad from "./EventsSection/EventLoad";
|
|||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join("")}
|
||||
})
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
filesContent.innerHTML = `
|
||||
} else {
|
||||
filesContent.innerHTML = `
|
||||
<div class="text-center py-8 text-base-content/70">
|
||||
<iconify-icon icon="heroicons:document-duplicate" className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No files attached to this event</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modal.showModal();
|
||||
};
|
||||
|
||||
// Add downloadAllFiles function
|
||||
window.downloadAllFiles = async function () {
|
||||
const downloadBtn = document.getElementById(
|
||||
"downloadAllBtn",
|
||||
) as HTMLButtonElement;
|
||||
if (!downloadBtn) return;
|
||||
const originalBtnContent = downloadBtn.innerHTML;
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
downloadBtn.innerHTML =
|
||||
'<span class="loading loading-spinner loading-xs"></span> Preparing...';
|
||||
downloadBtn.disabled = true;
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
// Get current event files
|
||||
const baseUrl = "https://pocketbase.ieeeucsd.org";
|
||||
const collectionId = "events";
|
||||
const recordId = window.currentEventId;
|
||||
|
||||
// Get the current event from the window object
|
||||
const eventDataId = `event_${window.currentEventId}`;
|
||||
const event = window[eventDataId];
|
||||
|
||||
if (!event || !event.files || event.files.length === 0) {
|
||||
throw new Error("No files available to download");
|
||||
}
|
||||
|
||||
// Download each file and add to zip
|
||||
const filePromises = event.files.map(async (filename: string) => {
|
||||
const fileUrl = `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
|
||||
const response = await fetch(fileUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${filename}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
zip.file(filename, blob);
|
||||
});
|
||||
|
||||
await Promise.all(filePromises);
|
||||
modal.showModal();
|
||||
};
|
||||
|
||||
// Generate and download zip
|
||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||
const downloadUrl = URL.createObjectURL(zipBlob);
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = `${event.event_name}_files.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
// Add downloadAllFiles function
|
||||
window.downloadAllFiles = async function () {
|
||||
const downloadBtn = document.getElementById(
|
||||
"downloadAllBtn"
|
||||
) as HTMLButtonElement;
|
||||
if (!downloadBtn) return;
|
||||
const originalBtnContent = downloadBtn.innerHTML;
|
||||
|
||||
// Show success message
|
||||
toast.success("Files downloaded successfully!");
|
||||
} catch (error: any) {
|
||||
console.error("Failed to download files:", error);
|
||||
toast.error(
|
||||
error?.message || "Failed to download files. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
// Reset button state
|
||||
downloadBtn.innerHTML = originalBtnContent;
|
||||
downloadBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
try {
|
||||
// Show loading state
|
||||
downloadBtn.innerHTML =
|
||||
'<span class="loading loading-spinner loading-xs"></span> Preparing...';
|
||||
downloadBtn.disabled = true;
|
||||
|
||||
// Close event details modal
|
||||
window.closeEventDetailsModal = function () {
|
||||
const modal = document.getElementById(
|
||||
"eventDetailsModal",
|
||||
) as HTMLDialogElement;
|
||||
const filesContent = document.getElementById("filesContent");
|
||||
const zip = new JSZip();
|
||||
|
||||
if (modal) {
|
||||
// Reset the files content
|
||||
if (filesContent) {
|
||||
filesContent.innerHTML = "";
|
||||
}
|
||||
// Get current event files
|
||||
const baseUrl = "https://pocketbase.ieeeucsd.org";
|
||||
const collectionId = "events";
|
||||
const recordId = window.currentEventId;
|
||||
|
||||
// Reset any other state if needed
|
||||
window.currentEventId = "";
|
||||
// Get the current event from the window object
|
||||
const eventDataId = `event_${window.currentEventId}`;
|
||||
const event = window[eventDataId];
|
||||
|
||||
// Close the modal
|
||||
modal.close();
|
||||
}
|
||||
};
|
||||
if (!event || !event.files || event.files.length === 0) {
|
||||
throw new Error("No files available to download");
|
||||
}
|
||||
|
||||
// Make helper functions available globally
|
||||
window.showFilePreview = window.showFilePreviewEvents;
|
||||
window.handlePreviewError = function () {
|
||||
const previewContent = document.getElementById("previewContent");
|
||||
if (previewContent) {
|
||||
previewContent.innerHTML = `
|
||||
// Download each file and add to zip
|
||||
const filePromises = event.files.map(async (filename: string) => {
|
||||
const fileUrl = `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
|
||||
const response = await fetch(fileUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${filename}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
zip.file(filename, blob);
|
||||
});
|
||||
|
||||
await Promise.all(filePromises);
|
||||
|
||||
// Generate and download zip
|
||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||
const downloadUrl = URL.createObjectURL(zipBlob);
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = `${event.event_name}_files.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
|
||||
// Show success message
|
||||
toast.success("Files downloaded successfully!");
|
||||
} catch (error: any) {
|
||||
console.error("Failed to download files:", error);
|
||||
toast.error(
|
||||
error?.message || "Failed to download files. Please try again."
|
||||
);
|
||||
} finally {
|
||||
// Reset button state
|
||||
downloadBtn.innerHTML = originalBtnContent;
|
||||
downloadBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Close event details modal
|
||||
window.closeEventDetailsModal = function () {
|
||||
const modal = document.getElementById(
|
||||
"eventDetailsModal"
|
||||
) as HTMLDialogElement;
|
||||
const filesContent = document.getElementById("filesContent");
|
||||
|
||||
if (modal) {
|
||||
// Reset the files content
|
||||
if (filesContent) {
|
||||
filesContent.innerHTML = "";
|
||||
}
|
||||
|
||||
// Reset any other state if needed
|
||||
window.currentEventId = "";
|
||||
|
||||
// Close the modal
|
||||
modal.close();
|
||||
}
|
||||
};
|
||||
|
||||
// Make helper functions available globally
|
||||
window.showFilePreview = window.showFilePreviewEvents;
|
||||
window.handlePreviewError = function () {
|
||||
const previewContent = document.getElementById("previewContent");
|
||||
if (previewContent) {
|
||||
previewContent.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
<Icon icon="heroicons:x-circle" className="h-6 w-6" />
|
||||
<span>Failed to load file preview</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -7,12 +7,11 @@ 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, LimitedUser } from "../../../schemas/pocketbase";
|
||||
import type { Event, EventAttendee } 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.
|
||||
|
@ -157,12 +156,7 @@ 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);
|
||||
}
|
||||
await dataSync.storeEventCode(eventCode);
|
||||
|
||||
// Show event details toast only for non-food events
|
||||
// For food events, we'll show the toast after food selection
|
||||
|
@ -186,7 +180,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 the food you ate (or will eat) at the event!</p>
|
||||
<p className="text-xs mt-1">Please select your food preference</p>
|
||||
</div>,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
|
@ -270,61 +264,23 @@ const EventCheckIn = () => {
|
|||
totalPoints += attendee.points_earned || 0;
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Log the points update
|
||||
// console.log(`Updating user points to: ${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);
|
||||
}
|
||||
// Update the user record with the new total points
|
||||
await update.updateFields(Collections.USERS, userId, {
|
||||
points: totalPoints
|
||||
});
|
||||
|
||||
// Ensure local data is in sync with backend
|
||||
// First sync the new attendance record
|
||||
try {
|
||||
await dataSync.syncCollection(Collections.EVENT_ATTENDEES);
|
||||
await dataSync.syncCollection(Collections.EVENT_ATTENDEES);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// Then sync the updated user data to ensure points are correctly reflected locally
|
||||
await dataSync.syncCollection(Collections.USERS);
|
||||
|
||||
// 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);
|
||||
}
|
||||
await dataSync.clearEventCode();
|
||||
|
||||
// Log successful check-in
|
||||
await logger.send(
|
||||
|
@ -403,12 +359,12 @@ const EventCheckIn = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="card bg-base-100 shadow-xl border border-base-200 h-full">
|
||||
<div className="card bg-card shadow-xl border border-border 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="form-control w-full">
|
||||
<label className="label">
|
||||
<span className="label-text text-sm sm:text-base">Enter event code to check in</span>
|
||||
<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>
|
||||
</label>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
@ -472,12 +428,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 ate (or will eat):</p>
|
||||
<p className="mb-4">This event has food! Please let us know what you'd like to eat:</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-control">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter the food you will or are eating"
|
||||
placeholder="Enter your food preference"
|
||||
className="input input-bordered w-full"
|
||||
value={foodInput}
|
||||
onChange={(e) => setFoodInput(e.target.value)}
|
||||
|
|
|
@ -10,7 +10,6 @@ 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 {
|
||||
|
@ -63,19 +62,6 @@ 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 () => {
|
||||
|
@ -117,28 +103,28 @@ const EventLoad = () => {
|
|||
}, []);
|
||||
|
||||
const createSkeletonCard = () => (
|
||||
<div className="card bg-base-200 shadow-lg animate-pulse">
|
||||
<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-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"></div>
|
||||
<div className="skeleton h-6 w-3/4 mb-2 bg-base-300 dark:bg-gray-700"></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="skeleton h-5 w-16"></div>
|
||||
<div className="skeleton h-5 w-20"></div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="skeleton h-5 w-24 mb-1"></div>
|
||||
<div className="skeleton h-4 w-16"></div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="skeleton h-4 w-full mb-3"></div>
|
||||
<div className="skeleton h-4 w-full mb-3 bg-base-300 dark:bg-gray-700"></div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="skeleton h-4 w-4"></div>
|
||||
<div className="skeleton h-4 w-1/2"></div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -160,7 +146,7 @@ const EventLoad = () => {
|
|||
try {
|
||||
const get = Get.getInstance();
|
||||
const attendees = await get.getList<EventAttendee>(
|
||||
Collections.EVENT_ATTENDEES,
|
||||
"event_attendees",
|
||||
1,
|
||||
1,
|
||||
`user="${currentUser.id}" && event="${event.id}"`
|
||||
|
@ -168,38 +154,12 @@ 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) {
|
||||
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);
|
||||
if (cardElement && hasAttendedEvent) {
|
||||
const attendedBadge = cardElement.querySelector('.attended-badge');
|
||||
if (attendedBadge) {
|
||||
(attendedBadge as HTMLElement).style.display = 'flex';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -216,94 +176,59 @@ 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 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>
|
||||
|
||||
{/* 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-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 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"
|
||||
/>
|
||||
{hasAttended ? 'Attended' : 'Not Attended'}
|
||||
<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">
|
||||
{startDate.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
{" • "}
|
||||
{startDate.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
{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"
|
||||
>
|
||||
<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' : ''}`}>
|
||||
<Icon
|
||||
icon={hasAttended ? "heroicons:check-circle" : "heroicons:x-circle"}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{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>
|
||||
|
@ -555,9 +480,9 @@ const EventLoad = () => {
|
|||
return (
|
||||
<>
|
||||
{/* Ongoing Events */}
|
||||
<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 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-body p-4 sm:p-6">
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Ongoing Events</h3>
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">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>
|
||||
|
@ -567,9 +492,9 @@ const EventLoad = () => {
|
|||
</div>
|
||||
|
||||
{/* Upcoming Events */}
|
||||
<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 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-body p-4 sm:p-6">
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Upcoming Events</h3>
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">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>
|
||||
|
@ -579,9 +504,9 @@ const EventLoad = () => {
|
|||
</div>
|
||||
|
||||
{/* Past Events */}
|
||||
<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 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-body p-4 sm:p-6">
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Past Events</h3>
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">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>
|
||||
|
@ -600,14 +525,14 @@ const EventLoad = () => {
|
|||
<>
|
||||
{/* No Events Message */}
|
||||
{noEvents && (
|
||||
<div className="card bg-base-100 shadow-xl border border-base-200 mx-4 sm:mx-6 p-8">
|
||||
<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="text-center">
|
||||
<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">
|
||||
<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">
|
||||
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-base-content/70 mb-6">
|
||||
<ul className="list-disc text-left max-w-md mx-auto text-gray-600 dark:text-gray-300 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>
|
||||
|
@ -635,9 +560,9 @@ const EventLoad = () => {
|
|||
|
||||
{/* Ongoing Events */}
|
||||
{events.ongoing.length > 0 && (
|
||||
<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 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-body p-4 sm:p-6">
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Ongoing Events</h3>
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">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>
|
||||
|
@ -647,9 +572,9 @@ const EventLoad = () => {
|
|||
|
||||
{/* Upcoming Events */}
|
||||
{events.upcoming.length > 0 && (
|
||||
<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 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-body p-4 sm:p-6">
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Upcoming Events</h3>
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">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>
|
||||
|
@ -659,9 +584,9 @@ const EventLoad = () => {
|
|||
|
||||
{/* Past Events */}
|
||||
{events.past.length > 0 && (
|
||||
<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 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-body p-4 sm:p-6">
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Past Events</h3>
|
||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">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>
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
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;
|
||||
|
@ -56,50 +54,34 @@ export default function LeaderboardStats() {
|
|||
setLoading(true);
|
||||
|
||||
// Get all users without sorting - we'll sort on client side
|
||||
const response = await get.getList(Collections.LIMITED_USERS, 1, 500, '', '', {
|
||||
const response = await get.getList('limitedUser', 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 = processedUsers
|
||||
.filter(user => user.parsedPoints > 0)
|
||||
const leaderboardUsers = response.items
|
||||
.filter((user: any) =>
|
||||
user.points !== undefined &&
|
||||
user.points !== null &&
|
||||
user.points > 0
|
||||
)
|
||||
// Sort by points descending
|
||||
.sort((a, b) => b.parsedPoints - a.parsedPoints);
|
||||
.sort((a: any, b: any) => b.points - a.points);
|
||||
|
||||
const totalUsers = leaderboardUsers.length;
|
||||
const totalPoints = leaderboardUsers.reduce((sum: number, user) => sum + user.parsedPoints, 0);
|
||||
const topScore = leaderboardUsers.length > 0 ? leaderboardUsers[0].parsedPoints : 0;
|
||||
const totalPoints = leaderboardUsers.reduce((sum: number, user: any) => sum + (user.points || 0), 0);
|
||||
const topScore = leaderboardUsers.length > 0 ? leaderboardUsers[0].points : 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 processed users, not just those with points > 0
|
||||
const currentUser = processedUsers.find(user => user.id === 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);
|
||||
|
||||
if (currentUser) {
|
||||
yourPoints = currentUser.parsedPoints || 0;
|
||||
yourPoints = currentUser.points || 0;
|
||||
|
||||
// Only calculate rank if user has points
|
||||
if (yourPoints > 0) {
|
||||
|
@ -137,15 +119,15 @@ export default function LeaderboardStats() {
|
|||
};
|
||||
|
||||
fetchStats();
|
||||
}, [get, isAuthenticated, currentUserId]);
|
||||
}, [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-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 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>
|
||||
))}
|
||||
</div>
|
||||
|
@ -154,27 +136,27 @@ export default function LeaderboardStats() {
|
|||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<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="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="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-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="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="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-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="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="mt-1 text-xs text-gray-500 dark:text-gray-400">Highest individual points</div>
|
||||
</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">Your Score</div>
|
||||
<div className="mt-2 text-3xl font-bold text-indigo-600 dark:text-indigo-400">
|
||||
<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">
|
||||
{isAuthenticated ? stats.yourPoints : '-'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Get } from '../../../scripts/pocketbase/Get';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import type { User, LimitedUser } from '../../../schemas/pocketbase/schema';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import type { User } from '../../../schemas/pocketbase/schema';
|
||||
|
||||
interface LeaderboardUser {
|
||||
id: string;
|
||||
|
@ -64,44 +63,21 @@ export default function LeaderboardTable() {
|
|||
setLoading(true);
|
||||
|
||||
// Fetch users without sorting - we'll sort on client side
|
||||
const response = await get.getList(Collections.LIMITED_USERS, 1, 100, '', '', {
|
||||
const response = await get.getList('limitedUser', 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<LimitedUser>) => user.id === currentUserId);
|
||||
currentUserData = response.items.find((user: Partial<User>) => 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 = processedUsers
|
||||
.filter(user => user.parsedPoints > 0)
|
||||
.sort((a, b) => (b.parsedPoints || 0) - (a.parsedPoints || 0))
|
||||
.map((user, index: number) => {
|
||||
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) => {
|
||||
// Check if this is the current user
|
||||
if (isAuthenticated && user.id === currentUserId) {
|
||||
setCurrentUserRank(index + 1);
|
||||
|
@ -110,7 +86,7 @@ export default function LeaderboardTable() {
|
|||
return {
|
||||
id: user.id || '',
|
||||
name: user.name || 'Anonymous User',
|
||||
points: user.parsedPoints,
|
||||
points: user.points || 0,
|
||||
avatar: user.avatar,
|
||||
major: user.major
|
||||
};
|
||||
|
@ -118,20 +94,16 @@ 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 && 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: currentUserProcessed.id || '',
|
||||
name: currentUserProcessed.name || 'Anonymous User',
|
||||
points: currentUserProcessed.parsedPoints || 0,
|
||||
avatar: currentUserProcessed.avatar,
|
||||
major: currentUserProcessed.major
|
||||
});
|
||||
}
|
||||
if (isAuthenticated && currentUserData &&
|
||||
!leaderboardUsers.some(user => user.id === currentUserId)) {
|
||||
// User isn't already in the list (has 0 points)
|
||||
leaderboardUsers.push({
|
||||
id: currentUserData.id || '',
|
||||
name: currentUserData.name || 'Anonymous User',
|
||||
points: currentUserData.points || 0,
|
||||
avatar: currentUserData.avatar,
|
||||
major: currentUserData.major
|
||||
});
|
||||
}
|
||||
|
||||
setUsers(leaderboardUsers);
|
||||
|
@ -145,7 +117,7 @@ export default function LeaderboardTable() {
|
|||
};
|
||||
|
||||
fetchLeaderboard();
|
||||
}, [get, isAuthenticated, currentUserId]);
|
||||
}, [isAuthenticated, currentUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery.trim() === '') {
|
||||
|
@ -212,37 +184,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-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"
|
||||
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"
|
||||
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">
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50/80 dark:bg-gray-800/80">
|
||||
<thead className="bg-base-200 dark:bg-gray-800/80">
|
||||
<tr>
|
||||
<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">
|
||||
<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">
|
||||
Rank
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-700 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-600 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-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Points
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white/90 dark:bg-gray-900/90 divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<tbody className="bg-base-100 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-indigo-50 dark:bg-indigo-900/20' : ''}>
|
||||
<tr key={user.id} className={isCurrentUser ? 'bg-primary/10 dark:bg-primary/20' : ''}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
{actualRank ? (
|
||||
actualRank <= 3 ? (
|
||||
|
@ -261,7 +233,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-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden relative">
|
||||
<div className="w-10 h-10 rounded-full bg-base-300 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} />
|
||||
) : (
|
||||
|
@ -283,7 +255,7 @@ export default function LeaderboardTable() {
|
|||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center font-bold text-indigo-600 dark:text-indigo-400">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center font-bold text-primary dark:text-primary">
|
||||
{user.points}
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -298,8 +270,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-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"
|
||||
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"
|
||||
onClick={() => paginate(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
|
@ -312,11 +284,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-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'
|
||||
}`}
|
||||
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`}
|
||||
onClick={() => paginate(i + 1)}
|
||||
>
|
||||
{i + 1}
|
||||
|
@ -324,8 +296,8 @@ export default function LeaderboardTable() {
|
|||
))}
|
||||
|
||||
<button
|
||||
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"
|
||||
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"
|
||||
onClick={() => paginate(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
|
@ -340,9 +312,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-gray-50/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<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">
|
||||
<p className="text-center text-sm text-gray-700 dark:text-gray-300">
|
||||
Your rank: <span className="font-bold text-indigo-600 dark:text-indigo-400">#{currentUserRank}</span>
|
||||
Your rank: <span className="font-bold text-primary dark:text-primary">#{currentUserRank}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
@ -351,7 +323,7 @@ export default function LeaderboardTable() {
|
|||
{isAuthenticated && currentUserId &&
|
||||
!currentUserRank &&
|
||||
currentUsers.some(user => user.id === currentUserId) && (
|
||||
<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">
|
||||
<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">
|
||||
<p className="text-center text-sm text-gray-700 dark:text-gray-300">
|
||||
Participate in events to earn points and get ranked!
|
||||
</p>
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
---
|
||||
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
|
@ -1,89 +0,0 @@
|
|||
---
|
||||
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>
|
|
@ -891,27 +891,27 @@ const currentPage = eventResponse.page;
|
|||
let start: Date, end: Date;
|
||||
|
||||
// Determine quarter (0-based months: 0-11)
|
||||
// Fall: Sept-Dec (8-11)
|
||||
// Winter: Jan-Mar (0-2)
|
||||
// Spring: Apr-Jun (3-5)
|
||||
// Summer: Jul-Sept (6-8)
|
||||
// Q1: Sept-Dec (8-11)
|
||||
// Q2: Jan-Mar (0-2)
|
||||
// Q3: Mar-Jun (2-5)
|
||||
// Q4: Jun-Sept (5-8)
|
||||
|
||||
if (month >= 8) {
|
||||
// Fall: Sept-Dec
|
||||
// Q1: Sept-Dec
|
||||
start = new Date(year, 8, 1);
|
||||
end = new Date(year, 11, 31);
|
||||
} else if (month >= 0 && month < 3) {
|
||||
// Winter: Jan-Mar
|
||||
} else if (month < 2) {
|
||||
// Q2: Jan-Mar
|
||||
start = new Date(year, 0, 1);
|
||||
end = new Date(year, 2, 31);
|
||||
} else if (month >= 3 && month < 6) {
|
||||
// Spring: Apr-Jun
|
||||
start = new Date(year, 3, 1);
|
||||
} else if (month < 5) {
|
||||
// Q3: Mar-Jun
|
||||
start = new Date(year, 2, 1);
|
||||
end = new Date(year, 5, 30);
|
||||
} else {
|
||||
// Summer: Jul-Sept
|
||||
start = new Date(year, 6, 1);
|
||||
end = new Date(year, 8, 30);
|
||||
// Q4: Jun-Sept
|
||||
start = new Date(year, 5, 1);
|
||||
end = new Date(year, 8, 0); // End on Aug 31
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
|
@ -924,14 +924,14 @@ const currentPage = eventResponse.page;
|
|||
if (month >= 8) {
|
||||
// Sept-Dec
|
||||
return "Fall";
|
||||
} else if (month >= 0 && month < 3) {
|
||||
} else if (month < 2) {
|
||||
// Jan-Mar
|
||||
return "Winter";
|
||||
} else if (month >= 3 && month < 6) {
|
||||
// Apr-Jun
|
||||
} else if (month < 5) {
|
||||
// Mar-Jun
|
||||
return "Spring";
|
||||
} else {
|
||||
// Jul-Sept
|
||||
// Jun-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 < 3; // Jan-Mar (0-2)
|
||||
isInQuarter = month >= 0 && month <= 2; // Jan-Mar
|
||||
break;
|
||||
case "spring":
|
||||
isInQuarter = month >= 3 && month < 6; // Apr-Jun (3-5)
|
||||
isInQuarter = month >= 2 && month <= 5; // Mar-Jun
|
||||
break;
|
||||
case "summer":
|
||||
isInQuarter = month >= 6 && month < 9; // Jul-Sept (6-8)
|
||||
isInQuarter = month >= 5 && month <= 8; // Jun-Sept
|
||||
break;
|
||||
}
|
||||
if (isInQuarter) {
|
||||
|
|
|
@ -5,7 +5,6 @@ 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';
|
||||
|
@ -133,28 +132,6 @@ 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">
|
||||
|
@ -165,7 +142,7 @@ const EventForm = memo(({
|
|||
type="number"
|
||||
name="editEventPoints"
|
||||
className="input input-bordered"
|
||||
value={event?.points_to_reward || ""}
|
||||
value={event?.points_to_reward || 0}
|
||||
onChange={(e) => handleChange('points_to_reward', Number(e.target.value))}
|
||||
min="0"
|
||||
required
|
||||
|
@ -263,15 +240,7 @@ 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')}`;
|
||||
// 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
|
||||
}
|
||||
});
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
setSelectedFiles(newFiles);
|
||||
|
@ -324,31 +293,6 @@ 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
|
||||
|
@ -457,7 +401,6 @@ interface EventChanges {
|
|||
end_date?: string;
|
||||
published?: boolean;
|
||||
has_food?: boolean;
|
||||
event_type?: string;
|
||||
}
|
||||
|
||||
interface FileChanges {
|
||||
|
@ -544,8 +487,7 @@ class ChangeTracker {
|
|||
'start_date',
|
||||
'end_date',
|
||||
'published',
|
||||
'has_food',
|
||||
'event_type'
|
||||
'has_food'
|
||||
];
|
||||
|
||||
for (const field of fields) {
|
||||
|
@ -608,12 +550,11 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
event_code: "",
|
||||
location: "",
|
||||
files: [],
|
||||
points_to_reward: null as unknown as number,
|
||||
points_to_reward: 0,
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
published: false,
|
||||
has_food: false,
|
||||
event_type: "other"
|
||||
has_food: false
|
||||
});
|
||||
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
|
@ -630,8 +571,7 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
auth: Authentication.getInstance(),
|
||||
update: Update.getInstance(),
|
||||
fileManager: FileManager.getInstance(),
|
||||
sendLog: SendLog.getInstance(),
|
||||
realtime: Realtime.getInstance()
|
||||
sendLog: SendLog.getInstance()
|
||||
}), []);
|
||||
|
||||
// Handle field changes
|
||||
|
@ -650,35 +590,17 @@ 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 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']
|
||||
}
|
||||
);
|
||||
// Fetch fresh event data
|
||||
const eventData = await services.get.getOne<Event>(Collections.EVENTS, eventId);
|
||||
|
||||
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
|
||||
|
@ -702,44 +624,15 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
event_code: eventData.event_code || '',
|
||||
location: eventData.location || '',
|
||||
files: eventData.files || [],
|
||||
points_to_reward: eventData.points_to_reward || null as unknown as number,
|
||||
points_to_reward: eventData.points_to_reward || 0,
|
||||
start_date: eventData.start_date || '',
|
||||
end_date: eventData.end_date || '',
|
||||
published: eventData.published || false,
|
||||
has_food: eventData.has_food || false,
|
||||
event_type: eventData.event_type || 'other'
|
||||
has_food: eventData.has_food || false
|
||||
});
|
||||
|
||||
// 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: '',
|
||||
|
@ -749,12 +642,11 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
event_code: '',
|
||||
location: '',
|
||||
files: [],
|
||||
points_to_reward: null as unknown as number,
|
||||
start_date: Get.formatLocalDate(now, false),
|
||||
end_date: Get.formatLocalDate(oneHourLater, false),
|
||||
points_to_reward: 0,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
published: false,
|
||||
has_food: false,
|
||||
event_type: "other"
|
||||
has_food: false
|
||||
});
|
||||
}
|
||||
setSelectedFiles(new Map());
|
||||
|
@ -764,10 +656,8 @@ 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]);
|
||||
}, [services.get]);
|
||||
|
||||
// Expose initializeEventData to window
|
||||
useEffect(() => {
|
||||
|
@ -808,12 +698,6 @@ 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: "",
|
||||
|
@ -823,12 +707,11 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
event_code: "",
|
||||
location: "",
|
||||
files: [],
|
||||
points_to_reward: null as unknown as number,
|
||||
points_to_reward: 0,
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
published: false,
|
||||
has_food: false,
|
||||
event_type: "other"
|
||||
has_food: false
|
||||
});
|
||||
setSelectedFiles(new Map());
|
||||
setFilesToDelete(new Set());
|
||||
|
@ -836,24 +719,12 @@ 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, services.realtime]);
|
||||
}, [hasUnsavedChanges, isSubmitting]);
|
||||
|
||||
// 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: "",
|
||||
|
@ -863,12 +734,11 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
event_code: "",
|
||||
location: "",
|
||||
files: [],
|
||||
points_to_reward: null as unknown as number,
|
||||
points_to_reward: 0,
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
published: false,
|
||||
has_food: false,
|
||||
event_type: "other"
|
||||
has_food: false
|
||||
});
|
||||
setSelectedFiles(new Map());
|
||||
setFilesToDelete(new Set());
|
||||
|
@ -876,15 +746,9 @@ 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();
|
||||
|
@ -914,12 +778,11 @@ 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: formData.get("editEventPoints") ? parseInt(formData.get("editEventPoints") as string) : null as unknown as number,
|
||||
points_to_reward: parseInt(formData.get("editEventPoints") as string) || 0,
|
||||
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",
|
||||
event_type: formData.get("editEventType") as string || "other"
|
||||
has_food: formData.get("editEventHasFood") === "on"
|
||||
};
|
||||
|
||||
// Log the update attempt
|
||||
|
|
|
@ -70,12 +70,6 @@ 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>('');
|
||||
|
@ -86,26 +80,11 @@ 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[];
|
||||
// Combine existing files with new files instead of replacing
|
||||
const combinedFiles = [...invoiceFiles, ...newFiles];
|
||||
setInvoiceFiles(combinedFiles);
|
||||
onDataChange({ invoice_files: combinedFiles });
|
||||
setInvoiceFiles(newFiles);
|
||||
onDataChange({ invoice_files: newFiles });
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
@ -143,19 +122,6 @@ 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()) {
|
||||
|
@ -215,9 +181,6 @@ 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,
|
||||
|
@ -249,17 +212,15 @@ 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[];
|
||||
// Combine existing files with new files instead of replacing
|
||||
const combinedFiles = [...invoiceFiles, ...newFiles];
|
||||
setInvoiceFiles(combinedFiles);
|
||||
onDataChange({ invoice_files: combinedFiles });
|
||||
setInvoiceFiles(newFiles);
|
||||
onDataChange({ invoice_files: newFiles });
|
||||
}
|
||||
};
|
||||
|
||||
// Handle invoice data change from the invoice builder
|
||||
const handleInvoiceDataChange = (data: InvoiceData) => {
|
||||
// Check budget limits and show toast if needed
|
||||
checkBudgetLimit(data.total);
|
||||
// Calculate if budget exceeds maximum allowed
|
||||
const maxBudget = Math.min(formData.expected_attendance * 10, 5000);
|
||||
|
||||
onDataChange({
|
||||
invoiceData: data,
|
||||
|
@ -328,44 +289,20 @@ 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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearAllFiles();
|
||||
}}
|
||||
className="btn btn-xs btn-outline btn-error"
|
||||
title="Clear all files"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
<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="text-xs text-gray-500">Click or drag to replace</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 (multiple files allowed)</p>
|
||||
<p className="text-xs text-gray-500">Supports PDF, JPG, JPEG, PNG</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -129,27 +129,7 @@ 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) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onChange={(e) => onDataChange({ start_date_time: e.target.value })}
|
||||
required
|
||||
whileHover="hover"
|
||||
variants={inputHoverVariants}
|
||||
|
@ -175,59 +155,25 @@ 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 ? (() => {
|
||||
try {
|
||||
const endDate = new Date(formData.end_date_time);
|
||||
if (isNaN(endDate.getTime())) return '';
|
||||
return endDate.toTimeString().substring(0, 5);
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
})() : ''}
|
||||
value={formData.end_date_time ? new Date(formData.end_date_time).toTimeString().substring(0, 5) : ''}
|
||||
onChange={(e) => {
|
||||
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] = 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: 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: '' });
|
||||
if (formData.start_date_time) {
|
||||
// Create a new date object from start_date_time
|
||||
const startDate = new Date(formData.start_date_time);
|
||||
// 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);
|
||||
// Update end_date_time with the new time but same date as start
|
||||
onDataChange({ end_date_time: startDate.toISOString() });
|
||||
}
|
||||
}}
|
||||
required
|
||||
disabled={!formData.start_date_time}
|
||||
whileHover="hover"
|
||||
variants={inputHoverVariants}
|
||||
/>
|
||||
<p className="text-xs text-base-content/60">
|
||||
{!formData.start_date_time
|
||||
? "Please set the start date and time first."
|
||||
: "The end time will use the same date as the start date."
|
||||
}
|
||||
The end time will use the same date as the start date.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
@ -7,7 +7,6 @@ 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';
|
||||
|
@ -70,13 +69,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 - MULTIPLE FILES
|
||||
other_logos: File[]; // Form uses File objects, schema uses strings
|
||||
advertising_format: string;
|
||||
will_or_have_room_booking: boolean;
|
||||
expected_attendance: number;
|
||||
room_booking_files: File[]; // CHANGED: Multiple room booking files instead of single
|
||||
room_booking: File | null;
|
||||
invoice: File | null;
|
||||
invoice_files: File[]; // MULTIPLE FILES
|
||||
invoice_files: File[];
|
||||
invoiceData: InvoiceData;
|
||||
needs_graphics?: boolean | null;
|
||||
needs_as_funding?: boolean | null;
|
||||
|
@ -89,6 +88,7 @@ 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_files: [],
|
||||
room_booking: null,
|
||||
as_funding_required: false,
|
||||
food_drinks_being_served: false,
|
||||
itemized_invoice: '',
|
||||
|
@ -134,10 +134,9 @@ const EventRequestForm: React.FC = () => {
|
|||
const dataToStore = {
|
||||
...formDataToSave,
|
||||
other_logos: [],
|
||||
room_booking_files: [],
|
||||
room_booking: null,
|
||||
invoice: null,
|
||||
invoice_files: [],
|
||||
savedAt: Date.now() // Add timestamp for stale data detection
|
||||
invoice_files: []
|
||||
};
|
||||
|
||||
localStorage.setItem('eventRequestFormData', JSON.stringify(dataToStore));
|
||||
|
@ -154,27 +153,12 @@ 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
|
||||
}));
|
||||
}
|
||||
setFormData(prevData => ({
|
||||
...prevData,
|
||||
...parsedData
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Error parsing saved form data:', e);
|
||||
// Clear corrupted data
|
||||
localStorage.removeItem('eventRequestFormData');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
@ -192,29 +176,9 @@ const EventRequestForm: React.FC = () => {
|
|||
}
|
||||
|
||||
setFormData(prevData => {
|
||||
const updatedData = { ...prevData, ...sectionData };
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
const updatedData = { ...prevData, ...sectionData };
|
||||
localStorage.setItem('eventRequestFormData', JSON.stringify(updatedData));
|
||||
return updatedData;
|
||||
});
|
||||
};
|
||||
|
@ -238,7 +202,7 @@ const EventRequestForm: React.FC = () => {
|
|||
advertising_format: '',
|
||||
will_or_have_room_booking: false,
|
||||
expected_attendance: 0,
|
||||
room_booking_files: [],
|
||||
room_booking: null, // No room booking by default
|
||||
as_funding_required: false,
|
||||
food_drinks_being_served: false,
|
||||
itemized_invoice: '',
|
||||
|
@ -272,6 +236,7 @@ const EventRequestForm: React.FC = () => {
|
|||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const auth = Authentication.getInstance();
|
||||
|
@ -302,36 +267,8 @@ const EventRequestForm: React.FC = () => {
|
|||
requested_user: userId,
|
||||
name: formData.name,
|
||||
location: formData.location,
|
||||
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();
|
||||
}
|
||||
})(),
|
||||
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(),
|
||||
event_description: formData.event_description,
|
||||
flyers_needed: formData.flyers_needed,
|
||||
photography_needed: formData.photography_needed,
|
||||
|
@ -340,14 +277,7 @@ 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 ? (() => {
|
||||
try {
|
||||
const advertDate = new Date(formData.flyer_advertising_start_date);
|
||||
return isNaN(advertDate.getTime()) ? '' : advertDate.toISOString();
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
})() : '',
|
||||
flyer_advertising_start_date: formData.flyer_advertising_start_date ? new Date(formData.flyer_advertising_start_date).toISOString() : '',
|
||||
flyer_additional_requests: formData.flyer_additional_requests,
|
||||
required_logos: formData.required_logos,
|
||||
advertising_format: formData.advertising_format,
|
||||
|
@ -372,126 +302,36 @@ 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 with deletion detection
|
||||
await dataSync.syncCollection(Collections.EVENT_REQUESTS, "", "-created", {}, true);
|
||||
// Force sync the event requests collection to update IndexedDB
|
||||
await dataSync.syncCollection(Collections.EVENT_REQUESTS);
|
||||
|
||||
console.log('Event request record created:', record.id);
|
||||
|
||||
// Upload files if they exist - handle each file type separately
|
||||
const fileUploadErrors: string[] = [];
|
||||
|
||||
// Upload other logos
|
||||
// Upload files if they exist
|
||||
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}`);
|
||||
}
|
||||
await fileManager.uploadFiles('event_request', record.id, 'other_logos', formData.other_logos);
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
if (formData.room_booking) {
|
||||
await fileManager.uploadFile('event_request', record.id, 'room_booking', formData.room_booking);
|
||||
}
|
||||
|
||||
// Upload invoice files
|
||||
// Upload multiple invoice files
|
||||
if (formData.invoice_files && formData.invoice_files.length > 0) {
|
||||
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');
|
||||
|
||||
// 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}`);
|
||||
await fileManager.appendFiles('event_request', record.id, 'invoice_files', formData.invoice_files);
|
||||
|
||||
// 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);
|
||||
}
|
||||
} 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!');
|
||||
await fileManager.uploadFile('event_request', record.id, 'invoice', formData.invoice);
|
||||
}
|
||||
|
||||
// Clear form data from localStorage
|
||||
localStorage.removeItem('eventRequestFormData');
|
||||
|
||||
// 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
|
||||
}
|
||||
// Keep success toast for form submission since it's a user action
|
||||
toast.success('Event request submitted successfully!');
|
||||
|
||||
// Reset form
|
||||
resetForm();
|
||||
|
@ -504,6 +344,7 @@ 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);
|
||||
}
|
||||
|
@ -566,47 +407,11 @@ 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 || formData.end_date_time.trim() === '') {
|
||||
if (!formData.end_date_time) {
|
||||
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() === '') {
|
||||
|
@ -614,14 +419,13 @@ const EventRequestForm: React.FC = () => {
|
|||
valid = false;
|
||||
}
|
||||
|
||||
if (formData.will_or_have_room_booking === undefined || formData.will_or_have_room_booking === null) {
|
||||
if (formData.will_or_have_room_booking === undefined) {
|
||||
errors.push('Room booking status is required');
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
// Show the first error as a toast instead of setting error state
|
||||
toast.error(errors[0]);
|
||||
setError(errors[0]);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -641,9 +445,9 @@ const EventRequestForm: React.FC = () => {
|
|||
return false;
|
||||
}
|
||||
|
||||
// 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');
|
||||
// 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');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -663,16 +467,10 @@ const EventRequestForm: React.FC = () => {
|
|||
|
||||
// Validate AS Funding Section
|
||||
const validateASFundingSection = () => {
|
||||
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;
|
||||
}
|
||||
|
||||
if (formData.as_funding_required) {
|
||||
// Check if invoice data is present and has items
|
||||
if (!formData.invoiceData || !formData.invoiceData.items || formData.invoiceData.items.length === 0) {
|
||||
toast.error('Please add at least one item to your invoice');
|
||||
setError('Please add at least one item to your invoice');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -684,7 +482,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) {
|
||||
toast.error(`Your budget ($${totalBudget.toFixed(2)}) exceeds the maximum allowed ($${maxBudget}). The absolute maximum is $5,000.`);
|
||||
setError(`Your budget (${totalBudget.toFixed(2)} dollars) exceeds the maximum allowed (${maxBudget} dollars). The absolute maximum is $5,000.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -1015,6 +813,21 @@ 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">
|
||||
|
|
|
@ -4,7 +4,6 @@ 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 = {
|
||||
|
@ -123,26 +122,11 @@ 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[];
|
||||
// Combine existing files with new files instead of replacing
|
||||
const combinedFiles = [...otherLogoFiles, ...newFiles];
|
||||
setOtherLogoFiles(combinedFiles);
|
||||
onDataChange({ other_logos: combinedFiles });
|
||||
setOtherLogoFiles(newFiles);
|
||||
onDataChange({ other_logos: newFiles });
|
||||
}
|
||||
};
|
||||
|
||||
// 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();
|
||||
|
@ -160,10 +144,8 @@ 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[];
|
||||
// Combine existing files with new files instead of replacing
|
||||
const combinedFiles = [...otherLogoFiles, ...newFiles];
|
||||
setOtherLogoFiles(combinedFiles);
|
||||
onDataChange({ other_logos: combinedFiles });
|
||||
setOtherLogoFiles(newFiles);
|
||||
onDataChange({ other_logos: newFiles });
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -367,44 +349,20 @@ 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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearAllLogoFiles();
|
||||
}}
|
||||
className="btn btn-xs btn-outline btn-error"
|
||||
title="Clear all files"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
<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="text-xs text-gray-500">Click or drag to replace</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, multiple files allowed)</p>
|
||||
<p className="text-xs text-gray-500">Please upload transparent logo files (PNG preferred)</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -4,7 +4,6 @@ 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 = {
|
||||
|
@ -70,12 +69,11 @@ interface TAPFormSectionProps {
|
|||
}
|
||||
|
||||
const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange }) => {
|
||||
const [roomBookingFiles, setRoomBookingFiles] = useState<File[]>(formData.room_booking_files || []);
|
||||
const [roomBookingFile, setRoomBookingFile] = useState<File | null>(formData.room_booking);
|
||||
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(() => {
|
||||
|
@ -91,58 +89,27 @@ 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 newFiles = Array.from(e.target.files) as File[];
|
||||
|
||||
// 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`);
|
||||
const file = e.target.files[0];
|
||||
|
||||
// Check file size - 1MB limit
|
||||
if (file.size > 1024 * 1024) {
|
||||
setFileError("Room booking file size must be under 1MB");
|
||||
return;
|
||||
}
|
||||
|
||||
setFileError(null);
|
||||
// Combine existing files with new files instead of replacing
|
||||
const combinedFiles = [...roomBookingFiles, ...newFiles];
|
||||
setRoomBookingFiles(combinedFiles);
|
||||
onDataChange({ room_booking_files: combinedFiles });
|
||||
setRoomBookingFile(file);
|
||||
onDataChange({ room_booking: file });
|
||||
|
||||
// Create preview URL for the first new file
|
||||
// Create preview URL
|
||||
if (filePreviewUrl) {
|
||||
URL.revokeObjectURL(filePreviewUrl);
|
||||
}
|
||||
const url = URL.createObjectURL(newFiles[0]);
|
||||
const url = URL.createObjectURL(file);
|
||||
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();
|
||||
|
@ -159,28 +126,24 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
|||
setIsDragging(false);
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
const newFiles = Array.from(e.dataTransfer.files) as File[];
|
||||
const file = e.dataTransfer.files[0];
|
||||
|
||||
// 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`);
|
||||
// Check file size - 1MB limit
|
||||
if (file.size > 1024 * 1024) {
|
||||
setFileError("Room booking file size must be under 1MB");
|
||||
return;
|
||||
}
|
||||
|
||||
setFileError(null);
|
||||
// Combine existing files with new files instead of replacing
|
||||
const combinedFiles = [...roomBookingFiles, ...newFiles];
|
||||
setRoomBookingFiles(combinedFiles);
|
||||
onDataChange({ room_booking_files: combinedFiles });
|
||||
setRoomBookingFile(file);
|
||||
onDataChange({ room_booking: file });
|
||||
|
||||
// Create preview URL for the first new file
|
||||
// Create preview URL
|
||||
if (filePreviewUrl) {
|
||||
URL.revokeObjectURL(filePreviewUrl);
|
||||
}
|
||||
const url = URL.createObjectURL(newFiles[0]);
|
||||
const url = URL.createObjectURL(file);
|
||||
setFilePreviewUrl(url);
|
||||
setSelectedPreviewFile(newFiles[0]);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -299,11 +262,6 @@ 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">
|
||||
|
@ -334,7 +292,6 @@ 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">
|
||||
|
@ -347,46 +304,16 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
|||
</svg>
|
||||
</motion.div>
|
||||
|
||||
{roomBookingFiles.length > 0 ? (
|
||||
{roomBookingFile ? (
|
||||
<>
|
||||
<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 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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
@ -402,20 +329,20 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
|||
)}
|
||||
|
||||
{/* Preview File Button - Outside the upload area */}
|
||||
{formData.will_or_have_room_booking && roomBookingFiles.length > 0 && (
|
||||
{formData.will_or_have_room_booking && roomBookingFile && (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={toggleFilePreview}
|
||||
>
|
||||
{showFilePreview ? 'Hide Preview' : `Preview Files (${roomBookingFiles.length})`}
|
||||
{showFilePreview ? 'Hide Preview' : 'Preview File'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Preview Component */}
|
||||
{showFilePreview && roomBookingFiles.length > 0 && (
|
||||
{showFilePreview && filePreviewUrl && roomBookingFile && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
@ -423,7 +350,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 ({roomBookingFiles.length} files)</h3>
|
||||
<h3 className="font-medium">File Preview</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-circle"
|
||||
|
@ -434,17 +361,7 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
<FilePreview url={filePreviewUrl} filename={roomBookingFile.name} />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
|
|
@ -258,14 +258,12 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
|||
return;
|
||||
}
|
||||
|
||||
// Use DataSyncService to get data from IndexedDB with forced sync and deletion detection
|
||||
// Use DataSyncService to get data from IndexedDB with forced sync
|
||||
const updatedRequests = await dataSync.getData<EventRequest>(
|
||||
Collections.EVENT_REQUESTS,
|
||||
true, // Force sync
|
||||
`requested_user="${userId}"`,
|
||||
'-created',
|
||||
{}, // expand
|
||||
true // Enable deletion detection for user-specific requests
|
||||
'-created'
|
||||
);
|
||||
|
||||
setEventRequests(updatedRequests);
|
||||
|
|
|
@ -53,7 +53,7 @@ try {
|
|||
"",
|
||||
"-created",
|
||||
{
|
||||
expand: "requested_user",
|
||||
expand: ["requested_user"],
|
||||
}
|
||||
)
|
||||
.catch((err) => {
|
||||
|
@ -308,7 +308,7 @@ try {
|
|||
Collections.EVENT_REQUESTS,
|
||||
"",
|
||||
"-created",
|
||||
"requested_user"
|
||||
{ expand: "requested_user" }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error during initial data sync:", err);
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence } 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
|
||||
|
@ -28,11 +29,10 @@ 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
|
||||
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
|
||||
room_reservation_needed?: boolean;
|
||||
room_reservation_location?: string;
|
||||
room_reservation_confirmed?: boolean;
|
||||
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 = (typeof fileName === 'string' ? fileName.split('.').pop()?.toLowerCase() : '') || '';
|
||||
const extension = 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-1 min-w-0">
|
||||
<div className="flex-grow">
|
||||
<p className="font-medium truncate" title={fileId}>
|
||||
{displayName}
|
||||
</p>
|
||||
|
@ -712,28 +712,6 @@ 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}
|
||||
|
@ -747,150 +725,6 @@ 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
|
||||
|
@ -1079,8 +913,6 @@ 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) => {
|
||||
|
@ -1099,33 +931,8 @@ 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() || '' : '';
|
||||
};
|
||||
|
@ -1140,7 +947,6 @@ 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);
|
||||
|
@ -1187,46 +993,6 @@ 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"
|
||||
|
@ -1504,9 +1270,6 @@ 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",
|
||||
|
@ -1537,14 +1300,8 @@ 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);
|
||||
}
|
||||
setNewStatus(newStatus);
|
||||
setIsConfirmModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmStatusChange = async () => {
|
||||
|
@ -1570,72 +1327,6 @@ 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 */}
|
||||
|
@ -1788,11 +1479,6 @@ 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>
|
||||
|
||||
|
@ -1817,14 +1503,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.will_or_have_room_booking ? 'badge-success' : 'badge-ghost'}`}>
|
||||
{request.will_or_have_room_booking ? 'Yes' : 'No'}
|
||||
<div className={`badge ${request.room_reservation_needed ? 'badge-success' : 'badge-ghost'}`}>
|
||||
{request.room_reservation_needed ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{request.will_or_have_room_booking ? (
|
||||
{request.room_reservation_needed ? (
|
||||
<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" />
|
||||
|
@ -1833,40 +1519,14 @@ 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.location || 'Not specified'}</p>
|
||||
<p className="text-white font-medium">{request.room_reservation_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_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 className={`badge ${request.room_reservation_confirmed ? 'badge-success' : 'badge-warning'}`}>
|
||||
{request.room_reservation_confirmed ? 'Confirmed' : 'Pending'}
|
||||
</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>
|
||||
|
@ -1957,75 +1617,8 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventRequestDetails;
|
||||
export default EventRequestDetails;
|
|
@ -1,8 +1,11 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
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 { 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';
|
||||
|
||||
|
@ -24,8 +27,6 @@ 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 {
|
||||
|
@ -44,18 +45,14 @@ 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>('active');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [sortField, setSortField] = useState<string>('start_date_time');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [sortField, setSortField] = useState<string>('created');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
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 () => {
|
||||
|
@ -68,14 +65,13 @@ const EventRequestManagementTable = ({
|
|||
|
||||
// console.log("Fetching event requests...");
|
||||
|
||||
// Use DataSyncService to get data from IndexedDB with forced sync and deletion detection
|
||||
// Use DataSyncService to get data from IndexedDB with forced sync
|
||||
const updatedRequests = await dataSync.getData<ExtendedEventRequest>(
|
||||
Collections.EVENT_REQUESTS,
|
||||
true, // Force sync
|
||||
'', // No filter - get all requests
|
||||
'', // No filter
|
||||
'-created',
|
||||
'requested_user', // Expand user data
|
||||
true // Enable deletion detection for all event requests
|
||||
'requested_user' // This is correct but we need to ensure it's working in the DataSyncService
|
||||
);
|
||||
|
||||
// If we still have "Unknown" users, try to fetch them directly
|
||||
|
@ -137,19 +133,9 @@ const EventRequestManagementTable = ({
|
|||
|
||||
// Apply status filter
|
||||
if (statusFilter !== 'all') {
|
||||
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();
|
||||
});
|
||||
}
|
||||
filtered = filtered.filter(request =>
|
||||
request.status?.toLowerCase() === statusFilter.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
|
@ -194,125 +180,40 @@ const EventRequestManagementTable = ({
|
|||
};
|
||||
|
||||
// Update event request status
|
||||
const updateEventRequestStatus = async (id: string, status: "submitted" | "pending" | "completed" | "declined", declineReason?: string): Promise<void> => {
|
||||
const updateEventRequestStatus = async (id: string, status: "submitted" | "pending" | "completed" | "declined"): Promise<void> => {
|
||||
try {
|
||||
// Find the event request to get its current status and name
|
||||
await onStatusChange(id, status);
|
||||
|
||||
// Find the event request to get its name
|
||||
const eventRequest = eventRequests.find(req => req.id === id);
|
||||
const eventName = eventRequest?.name || 'Event';
|
||||
const previousStatus = eventRequest?.status;
|
||||
|
||||
// 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
|
||||
request.id === id ? { ...request, status } : request
|
||||
)
|
||||
);
|
||||
|
||||
setFilteredRequests(prev =>
|
||||
prev.map(request =>
|
||||
request.id === id ? {
|
||||
...request,
|
||||
status,
|
||||
...(status === 'declined' && declineReason ? { declined_reason: declineReason } : {})
|
||||
} : request
|
||||
request.id === id ? { ...request, status } : request
|
||||
)
|
||||
);
|
||||
|
||||
// Force sync to update IndexedDB
|
||||
await dataSync.syncCollection<ExtendedEventRequest>(Collections.EVENT_REQUESTS);
|
||||
|
||||
// Show success toast with event name
|
||||
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
|
||||
// Find the event request to get its name
|
||||
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, flyers_completed: completed } : request
|
||||
)
|
||||
);
|
||||
|
||||
setFilteredRequests(prev =>
|
||||
prev.map(request =>
|
||||
request.id === id ? { ...request, flyers_completed: completed } : request
|
||||
)
|
||||
);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating PR status:', error);
|
||||
toast.error('Failed to update PR status');
|
||||
// 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
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -333,50 +234,6 @@ 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';
|
||||
|
@ -448,42 +305,10 @@ 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, eventRequests]);
|
||||
}, [statusFilter, searchTerm, sortField, sortDirection]);
|
||||
|
||||
// Check authentication and refresh token if needed
|
||||
useEffect(() => {
|
||||
|
@ -625,7 +450,6 @@ 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>
|
||||
|
@ -668,7 +492,7 @@ const EventRequestManagementTable = ({
|
|||
height: "auto"
|
||||
}}
|
||||
>
|
||||
<table className="table table-zebra w-full min-w-[600px]">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead className="bg-base-300/50 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th
|
||||
|
@ -689,7 +513,7 @@ const EventRequestManagementTable = ({
|
|||
onClick={() => handleSortChange('start_date_time')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Date & Time
|
||||
Date
|
||||
{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"} />
|
||||
|
@ -711,19 +535,6 @@ 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"
|
||||
|
@ -751,7 +562,7 @@ const EventRequestManagementTable = ({
|
|||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="w-20 min-w-[5rem]">Actions</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
@ -762,11 +573,7 @@ const EventRequestManagementTable = ({
|
|||
{truncateText(request.name, 30)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden md:table-cell">
|
||||
<div className="text-sm">
|
||||
{formatDateTimeRange(request.start_date_time, request.end_date_time)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden md:table-cell">{formatDate(request.start_date_time)}</td>
|
||||
<td>
|
||||
{(() => {
|
||||
const { name, email } = getUserDisplayInfo(request);
|
||||
|
@ -785,28 +592,6 @@ 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>
|
||||
|
@ -821,17 +606,16 @@ const EventRequestManagementTable = ({
|
|||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="btn btn-sm btn-primary btn-outline gap-1 min-h-[2rem] max-w-[5rem] flex-shrink-0"
|
||||
className="btn btn-sm btn-primary btn-outline btn-sm gap-2"
|
||||
onClick={() => openDetailModal(request)}
|
||||
title="View Event Details"
|
||||
>
|
||||
<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">
|
||||
<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="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>
|
||||
<span className="hidden sm:inline">View</span>
|
||||
View
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
@ -841,50 +625,6 @@ 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 } from '../../../schemas/pocketbase/schema';
|
||||
import { Collections, EventRequestStatus } 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,14 +269,9 @@ const EventRequestModal: React.FC<EventRequestModalProps> = ({ eventRequests })
|
|||
const update = Update.getInstance();
|
||||
await update.updateField("event_request", id, "status", status);
|
||||
|
||||
// Force sync to update IndexedDB with deletion detection enabled
|
||||
// Force sync to update IndexedDB
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
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;
|
||||
await dataSync.syncCollection(Collections.EVENT_REQUESTS);
|
||||
|
||||
// Update local state
|
||||
setLocalEventRequests(prevRequests =>
|
||||
|
@ -285,18 +280,13 @@ 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", {
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
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, User, LimitedUser } from "../../../schemas/pocketbase";
|
||||
import type { Event, Log, User } 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 member_type property
|
||||
// Extended User interface with points property
|
||||
interface ExtendedUser extends User {
|
||||
points?: number;
|
||||
member_type?: string;
|
||||
}
|
||||
|
||||
|
@ -82,44 +84,47 @@ export function Stats() {
|
|||
|
||||
setEventsAttended(attendedEvents.totalItems);
|
||||
|
||||
// Calculate points from attendees
|
||||
// Get user points - either from the user record or calculate from attendees
|
||||
let totalPoints = 0;
|
||||
|
||||
// Calculate quarterly points
|
||||
const quarterStartDate = getCurrentQuarterStartDate();
|
||||
let pointsThisQuarter = 0;
|
||||
|
||||
// Calculate both total and quarterly points from attendees
|
||||
attendedEvents.items.forEach(attendee => {
|
||||
const points = attendee.points_earned || 0;
|
||||
totalPoints += points;
|
||||
// If user has points field, use that for total points
|
||||
if (currentUser && currentUser.points !== undefined) {
|
||||
totalPoints = currentUser.points;
|
||||
|
||||
const checkinDate = new Date(attendee.time_checked_in);
|
||||
if (checkinDate >= quarterStartDate) {
|
||||
pointsThisQuarter += 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;
|
||||
totalPoints += points;
|
||||
|
||||
// Try to get the LimitedUser record to check if points match
|
||||
try {
|
||||
const limitedUserRecord = await get.getOne(
|
||||
Collections.LIMITED_USERS,
|
||||
userId
|
||||
);
|
||||
const checkinDate = new Date(attendee.time_checked_in);
|
||||
if (checkinDate >= quarterStartDate) {
|
||||
pointsThisQuarter += points;
|
||||
}
|
||||
});
|
||||
|
||||
if (limitedUserRecord && limitedUserRecord.points) {
|
||||
// Update the user record with calculated points if needed
|
||||
if (currentUser) {
|
||||
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);
|
||||
const update = Update.getInstance();
|
||||
await update.updateFields(Collections.USERS, currentUser.id, {
|
||||
points: totalPoints
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating user points:", error);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// LimitedUser record might not exist yet, that's okay
|
||||
}
|
||||
|
||||
setPointsEarned(totalPoints);
|
||||
|
@ -194,7 +199,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">Points</div>
|
||||
<div className="stat-title font-medium opacity-80">Loyalty 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">
|
||||
|
|
|
@ -1,187 +0,0 @@
|
|||
---
|
||||
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>
|
|
@ -1,194 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,172 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,254 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -5,6 +5,7 @@ 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
|
||||
|
@ -130,6 +131,27 @@ 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"
|
||||
|
@ -193,11 +215,13 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
|
|||
|
||||
<!-- Display Settings Card -->
|
||||
<div
|
||||
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform"
|
||||
class="card bg-card shadow-xl border border-border 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="badge badge-primary p-3">
|
||||
<div
|
||||
class="inline-flex items-center justify-center p-3 rounded-full bg-primary text-primary-foreground"
|
||||
>
|
||||
<Icon name="heroicons:computer-desktop" class="h-5 w-5" />
|
||||
</div>
|
||||
Display Settings
|
||||
|
@ -205,14 +229,18 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
|
|||
<p class="text-sm opacity-70 mb-4">
|
||||
Customize your dashboard appearance and display preferences
|
||||
</p>
|
||||
<div class="divider"></div>
|
||||
<div class="h-px w-full bg-border my-4"></div>
|
||||
|
||||
<div class="alert alert-warning mb-4">
|
||||
<div
|
||||
class="flex p-4 mb-4 text-sm rounded-lg bg-warning/20 text-warning-foreground"
|
||||
role="alert"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
class="flex-shrink-0 w-5 h-5 mr-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
|
|
|
@ -59,7 +59,16 @@ export default function AccountSecuritySettings({
|
|||
checkAuth();
|
||||
}, []);
|
||||
|
||||
// No logout functions needed here as logout is handled in the dashboard menu
|
||||
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.');
|
||||
}
|
||||
};
|
||||
|
||||
const detectBrowser = (userAgent: string): string => {
|
||||
if (userAgent.indexOf('Chrome') > -1) return 'Chrome';
|
||||
|
@ -170,13 +179,17 @@ 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>
|
||||
|
|
|
@ -258,17 +258,17 @@ export default function DisplaySettings() {
|
|||
{/* Theme Settings */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-lg mb-2">Theme</h4>
|
||||
<div className="form-control w-full max-w-xs">
|
||||
<div className="w-full max-w-xs">
|
||||
<select
|
||||
value={theme}
|
||||
onChange={handleThemeChange}
|
||||
className="select select-bordered"
|
||||
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"
|
||||
>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">Select your preferred theme</span>
|
||||
<label className="mt-1 block">
|
||||
<span className="text-xs text-muted-foreground">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="form-control w-full max-w-xs">
|
||||
<div className="w-full max-w-xs">
|
||||
<select
|
||||
value={fontSize}
|
||||
onChange={handleFontSizeChange}
|
||||
className="select select-bordered"
|
||||
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"
|
||||
>
|
||||
<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="label">
|
||||
<span className="label-text-alt">Select your preferred font size</span>
|
||||
<label className="mt-1 block">
|
||||
<span className="text-xs text-muted-foreground">Select your preferred font size</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -297,54 +297,64 @@ export default function DisplaySettings() {
|
|||
<div>
|
||||
<h4 className="font-semibold text-lg mb-2">Accessibility</h4>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="cursor-pointer label justify-start gap-4">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={colorBlindMode}
|
||||
onChange={handleColorBlindModeChange}
|
||||
className="toggle toggle-primary"
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-control mt-2">
|
||||
<label className="cursor-pointer label justify-start gap-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reducedMotion}
|
||||
onChange={handleReducedMotionChange}
|
||||
className="toggle toggle-primary"
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div>
|
||||
<span className="label-text font-medium">Reduced Motion</span>
|
||||
<p className="text-xs opacity-70">Minimizes animations and transitions</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-info">
|
||||
<p className="text-sm text-blue-500 dark:text-blue-400 mt-4">
|
||||
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="form-control">
|
||||
<div className="mt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{hasChanges && (
|
||||
<p className="text-sm text-warning">
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
You have unsaved changes. Click "Save Settings" to apply them.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-primary ${saving ? 'loading' : ''}`}
|
||||
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' : ''}`}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
{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'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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@ieeeatucsd.org" className="underline">webmaster@ieeeatucsd.org</a>
|
||||
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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
99
src/components/dashboard/SponsorAnalytics.astro
Normal file
99
src/components/dashboard/SponsorAnalytics.astro
Normal file
|
@ -0,0 +1,99 @@
|
|||
---
|
||||
// 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>
|
|
@ -1,334 +0,0 @@
|
|||
---
|
||||
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>
|
|
@ -1,249 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,334 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,198 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,205 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,262 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
78
src/components/dashboard/SponsorDashboard.astro
Normal file
78
src/components/dashboard/SponsorDashboard.astro
Normal file
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
// 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>
|
|
@ -4,7 +4,6 @@ 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;
|
||||
|
@ -67,35 +66,6 @@ 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]) {
|
||||
|
@ -174,69 +144,6 @@ 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 }}
|
||||
|
@ -248,11 +155,7 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
|||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
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'
|
||||
}}
|
||||
className="space-y-4 overflow-y-auto max-h-[70vh] pr-4 custom-scrollbar"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<AnimatePresence mode="wait">
|
||||
|
@ -288,183 +191,78 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
|||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 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>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={date}
|
||||
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>
|
||||
{/* Date */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Date</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* 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>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={locationName}
|
||||
onChange={(e) => setLocationName(e.target.value)}
|
||||
placeholder="Store/vendor name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Location Address</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={locationAddress}
|
||||
onChange={(e) => setLocationAddress(e.target.value)}
|
||||
placeholder="Full address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/* Location Name */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Location Name</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={locationName}
|
||||
onChange={(e) => setLocationName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Notes - Reduced height */}
|
||||
{/* Location Address */}
|
||||
<motion.div variants={itemVariants} className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Location Address</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||
value={locationAddress}
|
||||
onChange={(e) => setLocationAddress(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Notes */}
|
||||
<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"
|
||||
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[100px]"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Additional notes..."
|
||||
rows={3}
|
||||
/>
|
||||
</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>
|
||||
|
@ -476,48 +274,33 @@ 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-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="card-body p-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="form-control">
|
||||
<label className="label py-1">
|
||||
<span className="label-text text-xs">Description</span>
|
||||
<label className="label">
|
||||
<span className="label-text">Description</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered input-sm"
|
||||
className="input input-bordered"
|
||||
value={item.description}
|
||||
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
|
||||
placeholder="What was purchased?"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="form-control">
|
||||
<label className="label py-1">
|
||||
<span className="label-text text-xs">Category</span>
|
||||
<label className="label">
|
||||
<span className="label-text">Category</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered select-sm w-full"
|
||||
className="select select-bordered"
|
||||
value={item.category}
|
||||
onChange={(e) => handleExpenseItemChange(index, 'category', e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
<option value="">Select category</option>
|
||||
{EXPENSE_CATEGORIES.map(category => (
|
||||
<option key={category} value={category}>{category}</option>
|
||||
))}
|
||||
|
@ -525,19 +308,29 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
|||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label py-1">
|
||||
<span className="label-text text-xs">Amount ($)</span>
|
||||
<label className="label">
|
||||
<span className="label-text">Amount ($)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
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
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-bordered"
|
||||
value={item.amount}
|
||||
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))}
|
||||
min="0"
|
||||
step="0.01"
|
||||
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>
|
||||
|
@ -545,41 +338,38 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
|||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* 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>
|
||||
{/* 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"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Total */}
|
||||
<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">
|
||||
<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">
|
||||
<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-sm text-base-content/70">
|
||||
<div className="flex justify-between items-center 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">
|
||||
<div className="flex justify-between items-center font-medium text-lg">
|
||||
<span>Total:</span>
|
||||
<span className="font-mono text-primary text-lg">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span>
|
||||
<span className="font-mono text-primary">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
@ -622,60 +412,13 @@ 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 shadow-sm relative"
|
||||
className="bg-base-200/50 backdrop-blur-sm rounded-xl p-4 shadow-sm"
|
||||
>
|
||||
{/* 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>
|
||||
<FilePreview
|
||||
url={previewUrl}
|
||||
filename={file?.name || ''}
|
||||
isModal={false}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
|
@ -696,4 +439,4 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
|||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,14 +1,13 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } 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 } from '../../../schemas/pocketbase';
|
||||
import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase';
|
||||
|
||||
interface ReceiptFormData {
|
||||
file: File;
|
||||
|
@ -278,34 +277,11 @@ export default function ReimbursementForm() {
|
|||
formData.append('receipts', JSON.stringify(request.receipts));
|
||||
formData.append('department', request.department);
|
||||
|
||||
// Create the reimbursement record
|
||||
const newReimbursement = await pb.collection('reimbursement').create(formData);
|
||||
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);
|
||||
}
|
||||
await dataSync.syncCollection(Collections.REIMBURSEMENTS);
|
||||
|
||||
// Reset form
|
||||
setRequest({
|
||||
|
@ -332,14 +308,6 @@ 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.');
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Get } from '../../../scripts/pocketbase/Get';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
|
@ -114,27 +114,6 @@ 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
|
||||
|
@ -177,7 +156,7 @@ export default function ReimbursementList() {
|
|||
// Use DataSyncService to get data from IndexedDB with forced sync
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
// Sync reimbursements collection with force sync
|
||||
// Sync reimbursements collection
|
||||
await dataSync.syncCollection(
|
||||
Collections.REIMBURSEMENTS,
|
||||
`submitted_by="${userId}"`,
|
||||
|
@ -185,10 +164,10 @@ export default function ReimbursementList() {
|
|||
'audit_notes'
|
||||
);
|
||||
|
||||
// Get reimbursements from IndexedDB with forced sync to ensure latest data
|
||||
// Get reimbursements from IndexedDB
|
||||
const reimbursementRecords = await dataSync.getData<ReimbursementRequest>(
|
||||
Collections.REIMBURSEMENTS,
|
||||
true, // Force sync to ensure we have the latest data
|
||||
false, // Don't force sync again
|
||||
`submitted_by="${userId}"`,
|
||||
'-created'
|
||||
);
|
||||
|
|
|
@ -5,7 +5,6 @@ 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';
|
||||
|
@ -33,10 +32,6 @@ 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 {
|
||||
|
@ -58,11 +53,7 @@ export default function ReimbursementManagementPortal() {
|
|||
department: [],
|
||||
dateRange: 'all',
|
||||
sortBy: 'date_of_purchase',
|
||||
sortOrder: 'desc',
|
||||
hidePaid: true,
|
||||
hideRejected: true,
|
||||
compactView: false,
|
||||
search: ''
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
const [auditNote, setAuditNote] = useState('');
|
||||
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||
|
@ -119,21 +110,6 @@ 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})`;
|
||||
|
@ -184,10 +160,11 @@ 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(
|
||||
|
@ -223,7 +200,7 @@ export default function ReimbursementManagementPortal() {
|
|||
|
||||
const validReceipts = receiptRecords.filter((r): r is ExtendedReceipt => r !== null);
|
||||
|
||||
receiptMap = Object.fromEntries(
|
||||
const receiptMap = Object.fromEntries(
|
||||
validReceipts.map(receipt => [receipt.id, receipt])
|
||||
);
|
||||
setReceipts(receiptMap);
|
||||
|
@ -240,52 +217,6 @@ 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.');
|
||||
|
@ -435,7 +366,7 @@ export default function ReimbursementManagementPortal() {
|
|||
};
|
||||
|
||||
// Update the updateStatus function
|
||||
const updateStatus = async (id: string, status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'in_progress' | 'paid', showToast: boolean = true) => {
|
||||
const updateStatus = async (id: string, status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'in_progress' | 'paid') => {
|
||||
try {
|
||||
setLoadingStatus(true);
|
||||
const update = Update.getInstance();
|
||||
|
@ -444,28 +375,15 @@ 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: previousStatus,
|
||||
from: selectedReimbursement?.status,
|
||||
to: status
|
||||
});
|
||||
|
||||
// Send email notification
|
||||
try {
|
||||
await EmailClient.notifyStatusChange(id, status, previousStatus, userId);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send email notification:', emailError);
|
||||
// Don't fail the entire operation if email fails
|
||||
}
|
||||
|
||||
if (showToast) {
|
||||
toast.success(`Reimbursement ${status} successfully`);
|
||||
}
|
||||
toast.success(`Reimbursement ${status} successfully`);
|
||||
await refreshAuditData(id);
|
||||
} catch (error) {
|
||||
console.error('Error updating status:', error);
|
||||
|
@ -565,7 +483,8 @@ export default function ReimbursementManagementPortal() {
|
|||
}
|
||||
}));
|
||||
|
||||
// Don't show the receipt modal when auditing
|
||||
setSelectedReceipt(receipt);
|
||||
setShowReceiptModal(true);
|
||||
toast.success('Receipt audited successfully');
|
||||
} catch (error) {
|
||||
console.error('Error auditing receipt:', error);
|
||||
|
@ -663,21 +582,6 @@ 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);
|
||||
|
@ -709,8 +613,8 @@ export default function ReimbursementManagementPortal() {
|
|||
try {
|
||||
setLoadingStatus(true);
|
||||
|
||||
// First update the status (passing false to suppress the toast message)
|
||||
await updateStatus(rejectingId, 'rejected', false);
|
||||
// First update the status
|
||||
await updateStatus(rejectingId, 'rejected');
|
||||
|
||||
// Then add the rejection reason as a public note
|
||||
const auth = Authentication.getInstance();
|
||||
|
@ -789,59 +693,12 @@ 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>
|
||||
<span className="badge badge-primary badge-md font-medium">
|
||||
{reimbursements.length} Total
|
||||
</span>
|
||||
</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">
|
||||
|
@ -897,7 +754,6 @@ 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">
|
||||
|
@ -950,7 +806,6 @@ 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">
|
||||
|
@ -969,8 +824,7 @@ export default function ReimbursementManagementPortal() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort Controls */}
|
||||
<div className="form-control">
|
||||
<div className="form-control md:col-span-2">
|
||||
<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" />
|
||||
|
@ -996,54 +850,6 @@ 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 ? (
|
||||
|
@ -1067,7 +873,7 @@ export default function ReimbursementManagementPortal() {
|
|||
</motion.div>
|
||||
) : (
|
||||
<AnimatePresence>
|
||||
<div className={`${filters.compactView ? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2' : 'space-y-4'}`}>
|
||||
<div className="space-y-4">
|
||||
{reimbursements.map((reimbursement, index) => (
|
||||
<motion.div
|
||||
key={reimbursement.id}
|
||||
|
@ -1078,76 +884,47 @@ 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">
|
||||
<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">
|
||||
<h3 className="font-bold text-lg group-hover:text-primary transition-colors truncate">
|
||||
{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">
|
||||
<h3 className="font-bold text-lg group-hover:text-primary transition-colors truncate">
|
||||
{reimbursement.title}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<div className="flex items-center gap-1.5 text-base-content/70">
|
||||
<Icon icon="heroicons:calendar" className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{new Date(reimbursement.date_of_purchase).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-base-content/70">
|
||||
<Icon icon="heroicons:building-office" className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{reimbursement.department}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<div className="flex items-center gap-1.5 text-base-content/70">
|
||||
<Icon icon="heroicons:calendar" className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{new Date(reimbursement.date_of_purchase).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-base-content/70">
|
||||
<Icon icon="heroicons:building-office" className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{reimbursement.department}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
||||
<span className="font-mono font-bold text-lg text-primary whitespace-nowrap">
|
||||
${reimbursement.total_amount.toFixed(2)}
|
||||
</span>
|
||||
<span className={`badge ${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'
|
||||
} gap-1.5 px-3 py-2.5 capitalize font-medium`}>
|
||||
<Icon icon={
|
||||
reimbursement.status === 'approved' ? 'heroicons:check-circle' :
|
||||
reimbursement.status === 'rejected' ? 'heroicons:x-circle' :
|
||||
reimbursement.status === 'under_review' ? 'heroicons:eye' :
|
||||
reimbursement.status === 'in_progress' ? 'heroicons:currency-dollar' :
|
||||
reimbursement.status === 'paid' ? 'heroicons:banknotes' :
|
||||
'heroicons:clock'
|
||||
} className="h-4 w-4 flex-shrink-0" />
|
||||
{reimbursement.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
||||
<span className="font-mono font-bold text-lg text-primary whitespace-nowrap">
|
||||
${reimbursement.total_amount.toFixed(2)}
|
||||
</span>
|
||||
<span className={`badge ${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'
|
||||
} gap-1.5 px-3 py-2.5 capitalize font-medium`}>
|
||||
<Icon icon={
|
||||
reimbursement.status === 'approved' ? 'heroicons:check-circle' :
|
||||
reimbursement.status === 'rejected' ? 'heroicons:x-circle' :
|
||||
reimbursement.status === 'under_review' ? 'heroicons:eye' :
|
||||
reimbursement.status === 'in_progress' ? 'heroicons:currency-dollar' :
|
||||
reimbursement.status === 'paid' ? 'heroicons:banknotes' :
|
||||
'heroicons:clock'
|
||||
} className="h-4 w-4 flex-shrink-0" />
|
||||
{reimbursement.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
@ -1932,4 +1709,4 @@ export default function ReimbursementManagementPortal() {
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -14,8 +14,6 @@ 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(() => {
|
||||
|
@ -26,51 +24,13 @@ 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 () => {
|
||||
// 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);
|
||||
console.error('Image failed to load:', 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
|
||||
const response = await fetch(url, {
|
||||
mode: 'cors',
|
||||
cache: 'no-cache' // Avoid caching issues
|
||||
});
|
||||
// console.log('Trying to fetch image as blob:', url);
|
||||
const response = await fetch(url, { mode: 'cors' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
|
@ -78,24 +38,27 @@ 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.');
|
||||
|
||||
// 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.');
|
||||
}
|
||||
// Log additional details
|
||||
// console.log('Image URL that failed:', url);
|
||||
// console.log('Current auth status:',
|
||||
// Authentication.getInstance().isAuthenticated() ? 'Authenticated' : 'Not authenticated'
|
||||
// );
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={filename || 'Image preview'}
|
||||
alt={filename}
|
||||
className="max-w-full h-auto rounded-lg"
|
||||
loading="lazy"
|
||||
onError={handleError}
|
||||
|
@ -204,22 +167,6 @@ 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 => ({
|
||||
|
@ -228,377 +175,12 @@ 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;
|
||||
}
|
||||
|
||||
// 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()
|
||||
});
|
||||
}
|
||||
// Rest of your existing loadContent logic
|
||||
// ... existing content loading code ...
|
||||
} catch (err) {
|
||||
console.error('Error loading content:', err);
|
||||
setState(prev => ({
|
||||
|
@ -611,20 +193,8 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
|||
}, [state.url]);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
if (!state.url || (!state.isVisible && isModal)) return;
|
||||
loadContent();
|
||||
}, [state.url, state.isVisible, isModal, loadContent]);
|
||||
|
||||
// Intersection observer effect
|
||||
|
@ -794,14 +364,7 @@ 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();
|
||||
}, [loadContent]);
|
||||
|
||||
// If URL is empty, show a message
|
||||
|
@ -836,8 +399,7 @@ 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">
|
||||
{/* Initial loading state - show immediately when URL is provided but content hasn't loaded yet */}
|
||||
{state.url && !state.loading && !state.error && state.content === null && (
|
||||
{!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>
|
||||
|
@ -886,38 +448,21 @@ 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.'
|
||||
}));
|
||||
}}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
<video
|
||||
controls
|
||||
className="max-w-full h-auto rounded-lg"
|
||||
preload="metadata"
|
||||
onError={(e) => {
|
||||
console.error('Video failed to load:', e);
|
||||
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>
|
||||
)}
|
||||
|
||||
|
@ -977,41 +522,6 @@ 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' : ''}`}>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||
import type { User } from "../../../schemas/pocketbase/schema";
|
||||
import FirstTimeLoginPopup from "./FirstTimeLoginPopup";
|
||||
|
||||
interface FirstTimeLoginManagerProps {
|
||||
|
|
|
@ -84,15 +84,20 @@ export default function ThemeToggle() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="dropdown dropdown-end">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={`btn btn-circle btn-sm ${isLoading ? 'loading' : ''}`}
|
||||
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' : ''}`}
|
||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}
|
||||
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme (Light mode is experimental)`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{!isLoading && (
|
||||
{isLoading ? (
|
||||
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
theme === 'light' ? (
|
||||
<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" />
|
||||
|
@ -104,9 +109,9 @@ export default function ThemeToggle() {
|
|||
)
|
||||
)}
|
||||
</button>
|
||||
<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>
|
||||
<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>
|
||||
<p>Light mode is experimental and not fully supported yet. Some UI elements may not display correctly.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,85 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -1,156 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -17,7 +17,7 @@ import { LiaDotCircle } from "react-icons/lia";
|
|||
To stay up to date, join discord server
|
||||
</p>
|
||||
</div>
|
||||
<Link href="https://discord.gg/ubr2suwc2f" target="_blank" className="mr-[20%] flex flex-col items-center">
|
||||
<Link href="https://www.facebook.com/ieeeucsd" 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>
|
||||
|
|
|
@ -36,13 +36,6 @@ 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"
|
||||
|
@ -57,13 +50,6 @@ 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"
|
||||
|
@ -72,19 +58,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: "Event Analytics"
|
||||
title: "Analytics"
|
||||
icon: "heroicons:chart-bar"
|
||||
role: "sponsor"
|
||||
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"
|
||||
component: "SponsorAnalytics"
|
||||
class: "text-warning hover:text-warning-focus"
|
||||
|
||||
# Administrator Menu
|
||||
adminDashboard:
|
||||
|
@ -117,13 +103,12 @@ categories:
|
|||
|
||||
officer:
|
||||
title: "Officer Menu"
|
||||
sections: ["eventManagement", "officerEmailManagement", "eventRequestForm"]
|
||||
sections: ["eventManagement", "eventRequestForm"]
|
||||
role: "general"
|
||||
|
||||
executive:
|
||||
title: "Executive Menu"
|
||||
sections:
|
||||
["reimbursementManagement", "eventRequestManagement", "officerManagement"]
|
||||
sections: ["reimbursementManagement", "eventRequestManagement"]
|
||||
role: "executive"
|
||||
|
||||
admin:
|
||||
|
@ -133,10 +118,10 @@ categories:
|
|||
|
||||
sponsor:
|
||||
title: "Sponsor Portal"
|
||||
sections: ["sponsorAnalytics", "resumeDatabase"]
|
||||
sections: ["sponsorDashboard", "sponsorAnalytics"]
|
||||
role: "sponsor"
|
||||
|
||||
account:
|
||||
title: "Account"
|
||||
sections: ["settings"]
|
||||
sections: ["settings", "logout"]
|
||||
role: "none"
|
||||
|
|
|
@ -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": "/projects/quarterly",
|
||||
"link": "/quarterly",
|
||||
"number": "01",
|
||||
"delay": "100"
|
||||
},
|
||||
|
|
|
@ -2,41 +2,42 @@
|
|||
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" 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" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>IEEEUCSD</title>
|
||||
<script
|
||||
src="https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js"
|
||||
></script>
|
||||
<script is:inline>
|
||||
// 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">
|
||||
<div class="text-white min-h-screen">
|
||||
<header class="sticky top-0 w-full z-[999]">
|
||||
<Navbar />
|
||||
</header>
|
||||
<main class="w-[95%] mx-auto">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</body>
|
||||
<html lang="en" class="w-full h-full m-0 bg-ieee-black">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>IEEEUCSD</title>
|
||||
<script
|
||||
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
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
</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 />
|
||||
</header>
|
||||
<main class="w-[95%] mx-auto">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -292,7 +292,7 @@ async function sendCredentialsEmail(
|
|||
|
||||
Please change your password after your first login.
|
||||
|
||||
If you have any questions, please contact webmaster@ieeeatucsd.org.
|
||||
If you have any questions, please contact webmaster@ieeeucsd.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@ieeeatucsd.org
|
||||
To: webmaster@ieeeucsd.org
|
||||
Subject: New IEEE Email Account Created
|
||||
|
||||
A new IEEE email account has been created:
|
||||
|
|
|
@ -1,120 +0,0 @@
|
|||
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' } }
|
||||
);
|
||||
}
|
||||
};
|
|
@ -1,155 +0,0 @@
|
|||
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' } }
|
||||
);
|
||||
}
|
||||
};
|
|
@ -1,910 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -1,98 +0,0 @@
|
|||
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' } }
|
||||
);
|
||||
}
|
||||
};
|
|
@ -1,258 +0,0 @@
|
|||
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' }
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
|
@ -1,82 +0,0 @@
|
|||
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");
|
||||
}
|
||||
};
|
|
@ -9,8 +9,10 @@ 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";
|
||||
|
||||
|
@ -40,7 +42,7 @@ const components = Object.fromEntries(
|
|||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
@ -49,6 +51,10 @@ 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 -->
|
||||
|
@ -151,12 +157,8 @@ const components = Object.fromEntries(
|
|||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<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"
|
||||
>
|
||||
<nav class="flex-1 overflow-y-auto scrollbar-hide py-6">
|
||||
<ul class="menu gap-2 px-4 text-base-content/80">
|
||||
<!-- Loading Skeleton -->
|
||||
<div id="menuLoadingSkeleton">
|
||||
{
|
||||
|
@ -242,20 +244,6 @@ 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>
|
||||
|
@ -405,6 +393,7 @@ 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();
|
||||
|
@ -415,6 +404,11 @@ 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");
|
||||
|
@ -475,180 +469,6 @@ 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 =
|
||||
|
@ -668,7 +488,8 @@ const components = Object.fromEntries(
|
|||
|
||||
// Handle logout button
|
||||
if (sectionKey === "logout") {
|
||||
showLogoutConfirmation();
|
||||
auth.logout();
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -1077,6 +898,14 @@ 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) {
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
---
|
||||
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>
|
|
@ -3,25 +3,15 @@ import Layout from "../layouts/Layout.astro";
|
|||
const title = "Authenticating...";
|
||||
---
|
||||
|
||||
<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>
|
||||
import { RedirectHandler } from "../scripts/auth/RedirectHandler";
|
||||
new RedirectHandler();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<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>
|
||||
import { RedirectHandler } from "../scripts/auth/RedirectHandler";
|
||||
new RedirectHandler();
|
||||
</script>
|
||||
|
|
|
@ -1,156 +0,0 @@
|
|||
---
|
||||
// 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>
|
|
@ -32,7 +32,7 @@ export interface User extends BaseRecord {
|
|||
major?: string;
|
||||
zelle_information?: string;
|
||||
last_login?: string;
|
||||
// points?: number; // Total points earned from events (DEPRECATED)
|
||||
points?: number; // Total points earned from events
|
||||
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,18 +41,6 @@ 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
|
||||
|
@ -68,7 +56,6 @@ 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;
|
||||
}
|
||||
|
||||
|
@ -109,17 +96,16 @@ export interface EventRequest extends BaseRecord {
|
|||
event_description: string;
|
||||
flyers_needed: boolean;
|
||||
flyer_type?: string[]; // digital_with_social, digital_no_social, physical_with_advertising, physical_no_advertising, newsletter, other
|
||||
other_flyer_type?: string;
|
||||
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_files?: string[]; // CHANGED: Multiple files instead of single file
|
||||
room_booking?: string; // signle file
|
||||
as_funding_required: boolean;
|
||||
food_drinks_being_served: boolean;
|
||||
itemized_invoice?: string; // JSON string
|
||||
|
@ -128,7 +114,6 @@ 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;
|
||||
}
|
||||
|
||||
|
@ -232,7 +217,6 @@ export const Collections = {
|
|||
REIMBURSEMENTS: "reimbursement",
|
||||
RECEIPTS: "receipts",
|
||||
SPONSORS: "sponsors",
|
||||
LIMITED_USERS: "limitedUser",
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -106,7 +106,6 @@ 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) {
|
||||
|
@ -155,7 +154,7 @@ export class DataSyncService {
|
|||
}
|
||||
}
|
||||
|
||||
// Get data from PocketBase with expanded relations
|
||||
// Get data from PocketBase
|
||||
const items = await this.get.getAll<T>(collection, filter, sort, {
|
||||
expand: normalizedExpand,
|
||||
});
|
||||
|
@ -170,15 +169,12 @@ export class DataSyncService {
|
|||
return [];
|
||||
}
|
||||
|
||||
// Get existing items to handle conflicts and deletions
|
||||
// Get existing items to handle conflicts
|
||||
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) => {
|
||||
|
@ -210,43 +206,7 @@ export class DataSyncService {
|
|||
}),
|
||||
);
|
||||
|
||||
// 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
|
||||
// Store in IndexedDB
|
||||
await table.bulkPut(itemsToStore);
|
||||
|
||||
// Update last sync timestamp
|
||||
|
@ -488,7 +448,6 @@ 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);
|
||||
|
@ -505,7 +464,7 @@ export class DataSyncService {
|
|||
|
||||
if (!this.offlineMode && (forceSync || now - lastSync > syncThreshold)) {
|
||||
try {
|
||||
await this.syncCollection<T>(collection, filter, sort, expand, detectDeletions);
|
||||
await this.syncCollection<T>(collection, filter, sort, expand);
|
||||
} catch (error) {
|
||||
console.error(`Error syncing ${collection}, using cached data:`, error);
|
||||
}
|
||||
|
|
|
@ -1,264 +0,0 @@
|
|||
/**
|
||||
* 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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,112 +0,0 @@
|
|||
// 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(', ');
|
||||
}
|
|
@ -1,410 +0,0 @@
|
|||
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.';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,429 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -1,296 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
}
|
|
@ -1,203 +0,0 @@
|
|||
# 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
|
|
@ -1,310 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -37,38 +37,6 @@ 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"),
|
||||
|
|
Loading…
Reference in a new issue