Compare commits
65 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f1aa06f764 | ||
![]() |
7fd9ff922a | ||
![]() |
0825c281f2 | ||
![]() |
f97bbb0872 | ||
![]() |
f4ea7209f5 | ||
![]() |
6af12e59b7 | ||
![]() |
0d9b435097 | ||
![]() |
7d4e695d30 | ||
![]() |
32848a9a06 | ||
![]() |
b831e89f49 | ||
![]() |
8f6b9806a9 | ||
![]() |
eea220639c | ||
![]() |
be09ab6c44 | ||
![]() |
014d9492ac | ||
![]() |
4349b4d034 | ||
![]() |
a57a4e6889 | ||
![]() |
40b2ea48c1 | ||
![]() |
f75e1c6de1 | ||
![]() |
aac2837b78 | ||
![]() |
5d92bcfd1b | ||
![]() |
0584f160b2 | ||
![]() |
e8c932c3ce | ||
![]() |
a1e415aee4 | ||
![]() |
61089b0472 | ||
![]() |
52504aeb21 | ||
![]() |
216f48a572 | ||
![]() |
0b1ee708b9 | ||
![]() |
0c2fd1a8c2 | ||
![]() |
b5e9e599aa | ||
![]() |
216808ee18 | ||
![]() |
16ecd96ec6 | ||
![]() |
fb64e3421a | ||
![]() |
a303b7565b | ||
![]() |
f2bbd65db5 | ||
![]() |
effb8e22d7 | ||
![]() |
dff2679543 | ||
![]() |
c8b12d7dff | ||
![]() |
137a68c867 | ||
![]() |
1bfdf2cc31 | ||
![]() |
ad8abf9eaa | ||
![]() |
a714891543 | ||
![]() |
eb865dbd4c | ||
![]() |
27dd80481c | ||
![]() |
2a99e180bd | ||
![]() |
12207546de | ||
![]() |
43c1fc074a | ||
![]() |
2eaf5a230e | ||
![]() |
3533dc8048 | ||
![]() |
961ef1437a | ||
![]() |
1dec703780 | ||
![]() |
9e2345c0f7 | ||
![]() |
a935417a31 | ||
![]() |
60ffada68f | ||
![]() |
80f104a7d4 | ||
![]() |
4b3079a7f7 | ||
![]() |
d1fb2bc1c5 | ||
![]() |
236186bd39 | ||
![]() |
1f62063ed7 | ||
![]() |
733f3dc931 | ||
![]() |
10d3f32fbc | ||
![]() |
e68627f666 | ||
![]() |
11d5c4ad22 | ||
![]() |
4fdb29e8b0 | ||
![]() |
c0200a72a1 | ||
![]() |
ee67e0678e |
80 changed files with 13990 additions and 2192 deletions
1
.cursorignore
Normal file
1
.cursorignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -5,6 +5,8 @@ dist/
|
||||||
.astro/
|
.astro/
|
||||||
.cursor
|
.cursor
|
||||||
|
|
||||||
|
final_review_gate.py
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
|
55
Dockerfile
Normal file
55
Dockerfile
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
# Use the official Bun image
|
||||||
|
FROM oven/bun:1.1
|
||||||
|
|
||||||
|
# Install dependencies for Puppeteer and Chrome/Chromium
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y \
|
||||||
|
wget \
|
||||||
|
ca-certificates \
|
||||||
|
fonts-liberation \
|
||||||
|
libasound2 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libcups2 \
|
||||||
|
libdbus-1-3 \
|
||||||
|
libdrm2 \
|
||||||
|
libgbm1 \
|
||||||
|
libgtk-3-0 \
|
||||||
|
libnspr4 \
|
||||||
|
libnss3 \
|
||||||
|
libx11-xcb1 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxrandr2 \
|
||||||
|
xdg-utils \
|
||||||
|
chromium \
|
||||||
|
gnupg \
|
||||||
|
--no-install-recommends && \
|
||||||
|
# Install Google Chrome stable
|
||||||
|
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - && \
|
||||||
|
echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list && \
|
||||||
|
apt-get update && \
|
||||||
|
apt-get install -y google-chrome-stable && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set Puppeteer executable path (prefer google-chrome-stable, fallback to chromium)
|
||||||
|
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files and install dependencies
|
||||||
|
COPY bun.lock package.json ./
|
||||||
|
RUN bun install
|
||||||
|
|
||||||
|
# Copy the rest of your app
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# Expose the port your app runs on (change if needed)
|
||||||
|
EXPOSE 4321
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
CMD ["bun", "run", "start"]
|
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "4321:4321"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable
|
||||||
|
- PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||||
|
- PUPPETEER_DISABLE_SANDBOX=true
|
|
@ -1,4 +1,4 @@
|
||||||
[phases.setup]
|
[phases.setup]
|
||||||
nixPkgs = ["nodejs_18", "bun"]
|
nixPkgs = ["nodejs_20", "bun"]
|
||||||
aptPkgs = ["curl", "wget"]
|
aptPkgs = ["curl", "wget"]
|
||||||
|
|
||||||
|
|
28
package.json
28
package.json
|
@ -2,6 +2,9 @@
|
||||||
"name": "ieeeucsd-dev",
|
"name": "ieeeucsd-dev",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.20.8"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"start": "node ./dist/server/entry.mjs",
|
"start": "node ./dist/server/entry.mjs",
|
||||||
|
@ -10,10 +13,11 @@
|
||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "4.0.3",
|
"@astrojs/check": "^0.9.4",
|
||||||
"@astrojs/node": "^9.0.0",
|
"@astrojs/mdx": "^4.2.3",
|
||||||
"@astrojs/react": "^4.2.0",
|
"@astrojs/node": "^9.1.3",
|
||||||
"@astrojs/tailwind": "5.1.4",
|
"@astrojs/react": "^4.2.3",
|
||||||
|
"@astrojs/tailwind": "^6.0.2",
|
||||||
"@heroui/react": "^2.7.5",
|
"@heroui/react": "^2.7.5",
|
||||||
"@iconify-json/heroicons": "^1.2.2",
|
"@iconify-json/heroicons": "^1.2.2",
|
||||||
"@iconify-json/mdi": "^1.2.3",
|
"@iconify-json/mdi": "^1.2.3",
|
||||||
|
@ -21,9 +25,10 @@
|
||||||
"@types/highlight.js": "^10.1.0",
|
"@types/highlight.js": "^10.1.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/lodash": "^4.17.15",
|
||||||
"@types/react": "^19.0.8",
|
"@types/puppeteer": "^7.0.4",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react": "^19.1.0",
|
||||||
"astro": "5.1.1",
|
"@types/react-dom": "^19.1.1",
|
||||||
|
"astro": "^5.5.6",
|
||||||
"astro-expressive-code": "^0.40.2",
|
"astro-expressive-code": "^0.40.2",
|
||||||
"astro-icon": "^1.1.5",
|
"astro-icon": "^1.1.5",
|
||||||
"chart.js": "^4.4.7",
|
"chart.js": "^4.4.7",
|
||||||
|
@ -37,13 +42,16 @@
|
||||||
"next": "^15.1.2",
|
"next": "^15.1.2",
|
||||||
"pocketbase": "^0.25.1",
|
"pocketbase": "^0.25.1",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"react": "^19.0.0",
|
"puppeteer": "^24.10.1",
|
||||||
|
"react": "^19.1.0",
|
||||||
"react-chartjs-2": "^5.3.0",
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"rehype-expressive-code": "^0.40.2",
|
"rehype-expressive-code": "^0.40.2",
|
||||||
"tailwindcss": "^3.4.16"
|
"resend": "^4.5.1",
|
||||||
|
"tailwindcss": "^3.4.16",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
|
|
|
@ -5,151 +5,144 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
---
|
---
|
||||||
|
|
||||||
<div id="" class="">
|
<div id="" class="">
|
||||||
<div class="mb-4 sm:mb-6 px-4 sm:px-6">
|
<div class="mb-4 sm:mb-6 px-4 sm:px-6">
|
||||||
<h2 class="text-xl sm:text-2xl font-bold">Events</h2>
|
<h2 class="text-xl sm:text-2xl font-bold">Events</h2>
|
||||||
<p class="opacity-70 text-sm sm:text-base">
|
<p class="opacity-70 text-sm sm:text-base">
|
||||||
View and manage your IEEE UCSD events
|
View and manage your IEEE UCSD events
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div
|
<!-- Event Registration Card -->
|
||||||
class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 mb-4 sm:mb-6 px-4 sm:px-6"
|
<div class="w-full">
|
||||||
>
|
<div
|
||||||
<!-- Event Check-in Card -->
|
class="card bg-base-100 shadow-xl border border-base-200 opacity-50 cursor-not-allowed relative group h-full"
|
||||||
<div class="w-full">
|
>
|
||||||
<EventCheckIn client:load />
|
<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>
|
</div>
|
||||||
|
<div class="card-body p-4 sm:p-6">
|
||||||
<!-- Event Registration Card -->
|
<h3 class="card-title text-base sm:text-lg mb-3 sm:mb-4">
|
||||||
<div class="w-full">
|
Event Registration
|
||||||
<div
|
</h3>
|
||||||
class="card bg-card shadow-xl border border-border opacity-50 cursor-not-allowed relative group h-full"
|
<div class="form-control w-full">
|
||||||
>
|
<label class="label">
|
||||||
<div
|
<span class="label-text text-sm sm:text-base"
|
||||||
class="absolute inset-0 bg-card opacity-0 group-hover:opacity-90 transition-opacity duration-300 flex items-center justify-center z-10"
|
>Select an event to register</span
|
||||||
>
|
>
|
||||||
<span
|
</label>
|
||||||
class="text-card-foreground font-medium text-sm sm:text-base"
|
<div class="flex flex-col sm:flex-row gap-2">
|
||||||
>Coming Soon</span
|
<select
|
||||||
>
|
class="select select-bordered flex-1 text-sm sm:text-base h-10 min-h-[2.5rem] w-full"
|
||||||
</div>
|
disabled
|
||||||
<div class="card-body p-4 sm:p-6">
|
>
|
||||||
<h3 class="card-title text-base sm:text-lg mb-3 sm:mb-4">
|
<option disabled selected>Pick an event</option>
|
||||||
Event Registration
|
<option>Technical Workshop - Web Development</option>
|
||||||
</h3>
|
<option>Professional Development Workshop</option>
|
||||||
<div class="w-full">
|
<option>Social Event - Game Night</option>
|
||||||
<label class="block text-sm sm:text-base mb-2">
|
</select>
|
||||||
<span class="text-sm sm:text-base"
|
<button
|
||||||
>Select an event to register</span
|
class="btn btn-primary text-sm sm:text-base h-10 min-h-[2.5rem] w-full sm:w-[100px]"
|
||||||
>
|
disabled>Register</button
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<EventLoad client:load />
|
<EventLoad client:load />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Event Details Modal -->
|
<!-- Event Details Modal -->
|
||||||
<dialog id="eventDetailsModal" class="modal">
|
<dialog id="eventDetailsModal" class="modal">
|
||||||
<div class="modal-box max-w-[90vw] sm:max-w-4xl p-4 sm:p-6">
|
<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 justify-between items-center mb-3 sm:mb-4">
|
||||||
<div class="flex items-center gap-2 sm:gap-3">
|
<div class="flex items-center gap-2 sm:gap-3">
|
||||||
<h3 class="font-bold text-base sm:text-lg" id="modalTitle">
|
<h3 class="font-bold text-base sm:text-lg" id="modalTitle">
|
||||||
Event Files
|
Event Files
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
id="downloadAllBtn"
|
id="downloadAllBtn"
|
||||||
class="btn btn-primary btn-sm gap-1 text-xs sm:text-sm"
|
class="btn btn-primary btn-sm gap-1 text-xs sm:text-sm"
|
||||||
onclick="window.downloadAllFiles()"
|
onclick="window.downloadAllFiles()"
|
||||||
>
|
>
|
||||||
<iconify-icon
|
<iconify-icon
|
||||||
icon="heroicons:arrow-down-tray-20-solid"
|
icon="heroicons:arrow-down-tray-20-solid"
|
||||||
className="h-3 w-3 sm:h-4 sm:w-4"></iconify-icon>
|
className="h-3 w-3 sm:h-4 sm:w-4"></iconify-icon>
|
||||||
Download All
|
Download All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn btn-circle btn-ghost btn-sm sm:btn-md"
|
class="btn btn-circle btn-ghost btn-sm sm:btn-md"
|
||||||
onclick="window.closeEventDetailsModal()"
|
onclick="window.closeEventDetailsModal()"
|
||||||
>
|
>
|
||||||
<iconify-icon
|
<iconify-icon icon="heroicons:x-mark" className="h-4 w-4 sm:h-6 sm:w-6"
|
||||||
icon="heroicons:x-mark"
|
></iconify-icon>
|
||||||
className="h-4 w-4 sm:h-6 sm:w-6"></iconify-icon>
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div 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>
|
<div id="filesContent" class="space-y-3 sm:space-y-4">
|
||||||
</form>
|
<!-- Files list will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button onclick="window.closeEventDetailsModal()">close</button>
|
||||||
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<!-- Universal File Preview Modal -->
|
<!-- Universal File Preview Modal -->
|
||||||
<dialog id="filePreviewModal" class="modal">
|
<dialog id="filePreviewModal" class="modal">
|
||||||
<div class="modal-box max-w-[90vw] sm:max-w-4xl p-4 sm:p-6">
|
<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 justify-between items-center mb-3 sm:mb-4">
|
||||||
<div class="flex items-center gap-2 sm:gap-3">
|
<div class="flex items-center gap-2 sm:gap-3">
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-sm text-xs sm:text-sm"
|
class="btn btn-ghost btn-sm text-xs sm:text-sm"
|
||||||
onclick="window.closeFilePreviewEvents()">Close</button
|
onclick="window.closeFilePreviewEvents()">Close</button
|
||||||
>
|
>
|
||||||
<h3
|
<h3
|
||||||
class="font-bold text-base sm:text-lg truncate"
|
class="font-bold text-base sm:text-lg truncate"
|
||||||
id="previewFileName"
|
id="previewFileName"
|
||||||
>
|
>
|
||||||
</h3>
|
</h3>
|
||||||
</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>
|
</div>
|
||||||
<form method="dialog" class="modal-backdrop">
|
<div class="relative" id="previewContainer">
|
||||||
<button onclick="window.closeFilePreviewEvents()">close</button>
|
<div
|
||||||
</form>
|
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>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
|
|
||||||
// Add styles to the document
|
// Add styles to the document
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
/* Custom styles for the event details modal */
|
/* Custom styles for the event details modal */
|
||||||
.event-details-grid {
|
.event-details-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
@ -165,234 +158,227 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
|
|
||||||
/* Remove custom toast styles since we're using react-hot-toast */
|
/* 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
|
// Add helper functions for file preview
|
||||||
function getFileType(filename: string): string {
|
function getFileType(filename: string): string {
|
||||||
const extension = filename.split(".").pop()?.toLowerCase();
|
const extension = filename.split(".").pop()?.toLowerCase();
|
||||||
const mimeTypes: { [key: string]: string } = {
|
const mimeTypes: { [key: string]: string } = {
|
||||||
pdf: "application/pdf",
|
pdf: "application/pdf",
|
||||||
jpg: "image/jpeg",
|
jpg: "image/jpeg",
|
||||||
jpeg: "image/jpeg",
|
jpeg: "image/jpeg",
|
||||||
png: "image/png",
|
png: "image/png",
|
||||||
gif: "image/gif",
|
gif: "image/gif",
|
||||||
mp4: "video/mp4",
|
mp4: "video/mp4",
|
||||||
mp3: "audio/mpeg",
|
mp3: "audio/mpeg",
|
||||||
txt: "text/plain",
|
txt: "text/plain",
|
||||||
doc: "application/msword",
|
doc: "application/msword",
|
||||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
xls: "application/vnd.ms-excel",
|
xls: "application/vnd.ms-excel",
|
||||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
json: "application/json",
|
json: "application/json",
|
||||||
};
|
};
|
||||||
|
|
||||||
return mimeTypes[extension || ""] || "application/octet-stream";
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Universal file preview function for events section
|
if (!filename || typeof filename !== "string") {
|
||||||
window.previewFileEvents = function (url: string, filename: string) {
|
console.error(
|
||||||
// console.log("previewFileEvents called with:", { url, filename });
|
"Invalid filename provided to previewFileEvents:",
|
||||||
// console.log("URL type:", typeof url, "URL length:", url?.length || 0);
|
filename,
|
||||||
// console.log(
|
);
|
||||||
// "Filename type:",
|
toast.error("Cannot preview file: Invalid filename");
|
||||||
// typeof filename,
|
return;
|
||||||
// "Filename length:",
|
}
|
||||||
// filename?.length || 0
|
|
||||||
// );
|
|
||||||
|
|
||||||
// Validate inputs
|
// Ensure URL is properly formatted
|
||||||
if (!url || typeof url !== "string") {
|
if (!url.startsWith("http")) {
|
||||||
console.error("Invalid URL provided to previewFileEvents:", url);
|
console.warn("URL doesn't start with http, attempting to fix:", url);
|
||||||
toast.error("Cannot preview file: Invalid URL");
|
if (url.startsWith("/")) {
|
||||||
return;
|
url = `https://pocketbase.ieeeucsd.org${url}`;
|
||||||
}
|
} else {
|
||||||
|
url = `https://pocketbase.ieeeucsd.org/${url}`;
|
||||||
|
}
|
||||||
|
// console.log("Fixed URL:", url);
|
||||||
|
}
|
||||||
|
|
||||||
if (!filename || typeof filename !== "string") {
|
const modal = document.getElementById(
|
||||||
console.error(
|
"filePreviewModal",
|
||||||
"Invalid filename provided to previewFileEvents:",
|
) as HTMLDialogElement;
|
||||||
filename
|
const previewFileName = document.getElementById("previewFileName");
|
||||||
);
|
const previewContent = document.getElementById("previewContent");
|
||||||
toast.error("Cannot preview file: Invalid filename");
|
const loadingSpinner = document.getElementById("previewLoadingSpinner");
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure URL is properly formatted
|
if (modal && previewFileName && previewContent) {
|
||||||
if (!url.startsWith("http")) {
|
// console.log("Found all required elements");
|
||||||
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(
|
// Show loading spinner
|
||||||
"filePreviewModal"
|
if (loadingSpinner) {
|
||||||
) as HTMLDialogElement;
|
loadingSpinner.classList.remove("hidden");
|
||||||
const previewFileName = document.getElementById("previewFileName");
|
}
|
||||||
const previewContent = document.getElementById("previewContent");
|
|
||||||
const loadingSpinner = document.getElementById("previewLoadingSpinner");
|
|
||||||
|
|
||||||
if (modal && previewFileName && previewContent) {
|
// Update the filename display
|
||||||
// console.log("Found all required elements");
|
previewFileName.textContent = filename;
|
||||||
|
|
||||||
// Show loading spinner
|
// Show the modal
|
||||||
if (loadingSpinner) {
|
modal.showModal();
|
||||||
loadingSpinner.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the filename display
|
// Test the URL with a fetch before dispatching the event
|
||||||
previewFileName.textContent = filename;
|
fetch(url, { method: "HEAD" })
|
||||||
|
.then((response) => {
|
||||||
// Show the modal
|
// console.log(
|
||||||
modal.showModal();
|
// "URL test response:",
|
||||||
|
// response.status,
|
||||||
// Test the URL with a fetch before dispatching the event
|
// response.ok
|
||||||
fetch(url, { method: "HEAD" })
|
// );
|
||||||
.then((response) => {
|
if (!response.ok) {
|
||||||
// console.log(
|
console.warn("URL might not be accessible:", url);
|
||||||
// "URL test response:",
|
toast(
|
||||||
// response.status,
|
"File might not be accessible. Attempting to preview anyway.",
|
||||||
// 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");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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");
|
|
||||||
|
|
||||||
if (loadingSpinner) {
|
|
||||||
loadingSpinner.classList.add("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
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: "⚠️",
|
icon: "⚠️",
|
||||||
style: {
|
style: {
|
||||||
borderRadius: "10px",
|
borderRadius: "10px",
|
||||||
background: "#FFC107",
|
background: "#FFC107",
|
||||||
color: "#000",
|
color: "#000",
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
return;
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Reset state
|
// Close file preview for events section
|
||||||
window.currentEventId = event.id;
|
window.closeFilePreviewEvents = function () {
|
||||||
if (filesContent) filesContent.classList.remove("hidden");
|
// 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");
|
||||||
|
|
||||||
// Populate files content
|
if (loadingSpinner) {
|
||||||
if (
|
loadingSpinner.classList.add("hidden");
|
||||||
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 = `
|
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 = `
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table table-zebra w-full">
|
<table class="table table-zebra w-full">
|
||||||
<thead>
|
<thead>
|
||||||
|
@ -403,16 +389,16 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${event.files
|
${event.files
|
||||||
.map((file: string) => {
|
.map((file: string) => {
|
||||||
// Ensure the file URL is properly formatted
|
// Ensure the file URL is properly formatted
|
||||||
const fileUrl = `${baseUrl}/api/files/${collectionId}/${recordId}/${file}`;
|
const fileUrl = `${baseUrl}/api/files/${collectionId}/${recordId}/${file}`;
|
||||||
const fileType = getFileType(file);
|
const fileType = getFileType(file);
|
||||||
// Properly escape the data for the onclick handler
|
// Properly escape the data for the onclick handler
|
||||||
const fileData = {
|
const fileData = {
|
||||||
url: fileUrl,
|
url: fileUrl,
|
||||||
name: file,
|
name: file,
|
||||||
};
|
};
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${file}</td>
|
<td>${file}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
|
@ -425,123 +411,123 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
.join("")}
|
.join("")}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
filesContent.innerHTML = `
|
filesContent.innerHTML = `
|
||||||
<div class="text-center py-8 text-base-content/70">
|
<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" />
|
<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>
|
<p>No files attached to this event</p>
|
||||||
</div>
|
</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);
|
||||||
|
});
|
||||||
|
|
||||||
modal.showModal();
|
await Promise.all(filePromises);
|
||||||
};
|
|
||||||
|
|
||||||
// Add downloadAllFiles function
|
// Generate and download zip
|
||||||
window.downloadAllFiles = async function () {
|
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||||
const downloadBtn = document.getElementById(
|
const downloadUrl = URL.createObjectURL(zipBlob);
|
||||||
"downloadAllBtn"
|
const link = document.createElement("a");
|
||||||
) as HTMLButtonElement;
|
link.href = downloadUrl;
|
||||||
if (!downloadBtn) return;
|
link.download = `${event.event_name}_files.zip`;
|
||||||
const originalBtnContent = downloadBtn.innerHTML;
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(downloadUrl);
|
||||||
|
|
||||||
try {
|
// Show success message
|
||||||
// Show loading state
|
toast.success("Files downloaded successfully!");
|
||||||
downloadBtn.innerHTML =
|
} catch (error: any) {
|
||||||
'<span class="loading loading-spinner loading-xs"></span> Preparing...';
|
console.error("Failed to download files:", error);
|
||||||
downloadBtn.disabled = true;
|
toast.error(
|
||||||
|
error?.message || "Failed to download files. Please try again.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Reset button state
|
||||||
|
downloadBtn.innerHTML = originalBtnContent;
|
||||||
|
downloadBtn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const zip = new JSZip();
|
// Close event details modal
|
||||||
|
window.closeEventDetailsModal = function () {
|
||||||
|
const modal = document.getElementById(
|
||||||
|
"eventDetailsModal",
|
||||||
|
) as HTMLDialogElement;
|
||||||
|
const filesContent = document.getElementById("filesContent");
|
||||||
|
|
||||||
// Get current event files
|
if (modal) {
|
||||||
const baseUrl = "https://pocketbase.ieeeucsd.org";
|
// Reset the files content
|
||||||
const collectionId = "events";
|
if (filesContent) {
|
||||||
const recordId = window.currentEventId;
|
filesContent.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
// Get the current event from the window object
|
// Reset any other state if needed
|
||||||
const eventDataId = `event_${window.currentEventId}`;
|
window.currentEventId = "";
|
||||||
const event = window[eventDataId];
|
|
||||||
|
|
||||||
if (!event || !event.files || event.files.length === 0) {
|
// Close the modal
|
||||||
throw new Error("No files available to download");
|
modal.close();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Download each file and add to zip
|
// Make helper functions available globally
|
||||||
const filePromises = event.files.map(async (filename: string) => {
|
window.showFilePreview = window.showFilePreviewEvents;
|
||||||
const fileUrl = `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
|
window.handlePreviewError = function () {
|
||||||
const response = await fetch(fileUrl);
|
const previewContent = document.getElementById("previewContent");
|
||||||
if (!response.ok) {
|
if (previewContent) {
|
||||||
throw new Error(`Failed to download ${filename}`);
|
previewContent.innerHTML = `
|
||||||
}
|
|
||||||
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">
|
<div class="alert alert-error">
|
||||||
<Icon icon="heroicons:x-circle" className="h-6 w-6" />
|
<Icon icon="heroicons:x-circle" className="h-6 w-6" />
|
||||||
<span>Failed to load file preview</span>
|
<span>Failed to load file preview</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -7,11 +7,12 @@ import { DataSyncService } from "../../../scripts/database/DataSyncService";
|
||||||
import { Collections } from "../../../schemas/pocketbase/schema";
|
import { Collections } from "../../../schemas/pocketbase/schema";
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import type { Event, EventAttendee } from "../../../schemas/pocketbase";
|
import type { Event, EventAttendee, LimitedUser } from "../../../schemas/pocketbase";
|
||||||
|
|
||||||
// Extended Event interface with additional properties needed for this component
|
// Extended Event interface with additional properties needed for this component
|
||||||
interface ExtendedEvent extends Event {
|
interface ExtendedEvent extends Event {
|
||||||
description?: string; // This component uses 'description' but schema has 'event_description'
|
description?: string; // This component uses 'description' but schema has 'event_description'
|
||||||
|
event_type: string; // Add event_type field from schema
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Date conversion is now handled automatically by the Get and Update classes.
|
// Note: Date conversion is now handled automatically by the Get and Update classes.
|
||||||
|
@ -156,7 +157,12 @@ const EventCheckIn = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store event code in local storage for offline check-in
|
// Store event code in local storage for offline check-in
|
||||||
await dataSync.storeEventCode(eventCode);
|
try {
|
||||||
|
await dataSync.storeEventCode(eventCode);
|
||||||
|
} catch (syncError) {
|
||||||
|
// Log the error but don't show a toast to the user
|
||||||
|
console.error("Error storing event code locally:", syncError);
|
||||||
|
}
|
||||||
|
|
||||||
// Show event details toast only for non-food events
|
// Show event details toast only for non-food events
|
||||||
// For food events, we'll show the toast after food selection
|
// For food events, we'll show the toast after food selection
|
||||||
|
@ -180,7 +186,7 @@ const EventCheckIn = () => {
|
||||||
<div>
|
<div>
|
||||||
<strong>Event with food found!</strong>
|
<strong>Event with food found!</strong>
|
||||||
<p className="text-sm mt-1">{event.event_name}</p>
|
<p className="text-sm mt-1">{event.event_name}</p>
|
||||||
<p className="text-xs mt-1">Please select your food preference</p>
|
<p className="text-xs mt-1">Please select the food you ate (or will eat) at the event!</p>
|
||||||
</div>,
|
</div>,
|
||||||
{ duration: 5000 }
|
{ duration: 5000 }
|
||||||
);
|
);
|
||||||
|
@ -264,23 +270,61 @@ const EventCheckIn = () => {
|
||||||
totalPoints += attendee.points_earned || 0;
|
totalPoints += attendee.points_earned || 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log the points update
|
// Update the LimitedUser record with the new points total
|
||||||
// console.log(`Updating user points to: ${totalPoints}`);
|
try {
|
||||||
|
// Try to get the LimitedUser record to check if it exists
|
||||||
|
let limitedUserExists = false;
|
||||||
|
try {
|
||||||
|
const limitedUser = await get.getOne(Collections.LIMITED_USERS, userId);
|
||||||
|
limitedUserExists = !!limitedUser;
|
||||||
|
} catch (e) {
|
||||||
|
// Record doesn't exist
|
||||||
|
limitedUserExists = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Update the user record with the new total points
|
// Create or update the LimitedUser record
|
||||||
await update.updateFields(Collections.USERS, userId, {
|
if (limitedUserExists) {
|
||||||
points: totalPoints
|
await update.updateFields(Collections.LIMITED_USERS, userId, {
|
||||||
});
|
points: JSON.stringify(totalPoints),
|
||||||
|
total_events_attended: JSON.stringify(userAttendance.totalItems)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Get user data to create LimitedUser record
|
||||||
|
const userData = await get.getOne(Collections.USERS, userId);
|
||||||
|
if (userData) {
|
||||||
|
await update.create(Collections.LIMITED_USERS, {
|
||||||
|
id: userId, // Use same ID as user record
|
||||||
|
name: userData.name || 'Anonymous User',
|
||||||
|
major: userData.major || '',
|
||||||
|
points: JSON.stringify(totalPoints),
|
||||||
|
total_events_attended: JSON.stringify(userAttendance.totalItems)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update LimitedUser record:', error);
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure local data is in sync with backend
|
// Ensure local data is in sync with backend
|
||||||
// First sync the new attendance record
|
// First sync the new attendance record
|
||||||
await dataSync.syncCollection(Collections.EVENT_ATTENDEES);
|
try {
|
||||||
|
await dataSync.syncCollection(Collections.EVENT_ATTENDEES);
|
||||||
|
|
||||||
// Then sync the updated user data to ensure points are correctly reflected locally
|
// Then sync the updated user and LimitedUser data
|
||||||
await dataSync.syncCollection(Collections.USERS);
|
await dataSync.syncCollection(Collections.USERS);
|
||||||
|
await dataSync.syncCollection(Collections.LIMITED_USERS);
|
||||||
|
} catch (syncError) {
|
||||||
|
// Log the error but don't show a toast to the user
|
||||||
|
console.error('Local sync failed:', syncError);
|
||||||
|
}
|
||||||
|
|
||||||
// Clear event code from local storage
|
// Clear event code from local storage
|
||||||
await dataSync.clearEventCode();
|
try {
|
||||||
|
await dataSync.clearEventCode();
|
||||||
|
} catch (clearError) {
|
||||||
|
// Log the error but don't show a toast to the user
|
||||||
|
console.error("Error clearing event code from local storage:", clearError);
|
||||||
|
}
|
||||||
|
|
||||||
// Log successful check-in
|
// Log successful check-in
|
||||||
await logger.send(
|
await logger.send(
|
||||||
|
@ -359,12 +403,12 @@ const EventCheckIn = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="card bg-card shadow-xl border border-border h-full">
|
<div className="card bg-base-100 shadow-xl border border-base-200 h-full">
|
||||||
<div className="card-body p-4 sm:p-6">
|
<div className="card-body p-4 sm:p-6">
|
||||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Event Check-in</h3>
|
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Event Check-in</h3>
|
||||||
<div className="w-full">
|
<div className="form-control w-full">
|
||||||
<label className="block text-sm sm:text-base mb-2">
|
<label className="label">
|
||||||
<span className="text-sm sm:text-base">Enter event code to check in</span>
|
<span className="label-text text-sm sm:text-base">Enter event code to check in</span>
|
||||||
</label>
|
</label>
|
||||||
<form onSubmit={(e) => {
|
<form onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -428,12 +472,12 @@ const EventCheckIn = () => {
|
||||||
<div className="badge badge-primary mb-4">
|
<div className="badge badge-primary mb-4">
|
||||||
{currentCheckInEvent?.points_to_reward} points
|
{currentCheckInEvent?.points_to_reward} points
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-4">This event has food! Please let us know what you'd like to eat:</p>
|
<p className="mb-4">This event has food! Please let us know what you ate (or will eat):</p>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter your food preference"
|
placeholder="Enter the food you will or are eating"
|
||||||
className="input input-bordered w-full"
|
className="input input-bordered w-full"
|
||||||
value={foodInput}
|
value={foodInput}
|
||||||
onChange={(e) => setFoodInput(e.target.value)}
|
onChange={(e) => setFoodInput(e.target.value)}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import type { Event, AttendeeEntry, EventAttendee } from "../../../schemas/pocke
|
||||||
// Extended Event interface with additional properties needed for this component
|
// Extended Event interface with additional properties needed for this component
|
||||||
interface ExtendedEvent extends Event {
|
interface ExtendedEvent extends Event {
|
||||||
description?: string; // This component uses 'description' but schema has 'event_description'
|
description?: string; // This component uses 'description' but schema has 'event_description'
|
||||||
|
event_type: string; // Add event_type field from schema
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
@ -62,6 +63,19 @@ const EventLoad = () => {
|
||||||
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
|
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [expandedDescriptions, setExpandedDescriptions] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleDescription = (eventId: string) => {
|
||||||
|
setExpandedDescriptions(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(eventId)) {
|
||||||
|
newSet.delete(eventId);
|
||||||
|
} else {
|
||||||
|
newSet.add(eventId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Function to clear the events cache and force a fresh sync
|
// Function to clear the events cache and force a fresh sync
|
||||||
const refreshEvents = async () => {
|
const refreshEvents = async () => {
|
||||||
|
@ -103,28 +117,28 @@ const EventLoad = () => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const createSkeletonCard = () => (
|
const createSkeletonCard = () => (
|
||||||
<div className="card bg-base-200 dark:bg-gray-800/90 shadow-lg animate-pulse border border-base-300 dark:border-gray-700">
|
<div className="card bg-base-200 shadow-lg animate-pulse">
|
||||||
<div className="card-body p-5">
|
<div className="card-body p-5">
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex items-start justify-between gap-3 mb-2">
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="skeleton h-6 w-3/4 mb-2 bg-base-300 dark:bg-gray-700"></div>
|
<div className="skeleton h-6 w-3/4 mb-2"></div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="skeleton h-5 w-16 bg-base-300 dark:bg-gray-700"></div>
|
<div className="skeleton h-5 w-16"></div>
|
||||||
<div className="skeleton h-5 w-20 bg-base-300 dark:bg-gray-700"></div>
|
<div className="skeleton h-5 w-20"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
<div className="skeleton h-5 w-24 mb-1 bg-base-300 dark:bg-gray-700"></div>
|
<div className="skeleton h-5 w-24 mb-1"></div>
|
||||||
<div className="skeleton h-4 w-16 bg-base-300 dark:bg-gray-700"></div>
|
<div className="skeleton h-4 w-16"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="skeleton h-4 w-full mb-3 bg-base-300 dark:bg-gray-700"></div>
|
<div className="skeleton h-4 w-full mb-3"></div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="skeleton h-4 w-4 bg-base-300 dark:bg-gray-700"></div>
|
<div className="skeleton h-4 w-4"></div>
|
||||||
<div className="skeleton h-4 w-1/2 bg-base-300 dark:bg-gray-700"></div>
|
<div className="skeleton h-4 w-1/2"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -146,7 +160,7 @@ const EventLoad = () => {
|
||||||
try {
|
try {
|
||||||
const get = Get.getInstance();
|
const get = Get.getInstance();
|
||||||
const attendees = await get.getList<EventAttendee>(
|
const attendees = await get.getList<EventAttendee>(
|
||||||
"event_attendees",
|
Collections.EVENT_ATTENDEES,
|
||||||
1,
|
1,
|
||||||
1,
|
1,
|
||||||
`user="${currentUser.id}" && event="${event.id}"`
|
`user="${currentUser.id}" && event="${event.id}"`
|
||||||
|
@ -154,12 +168,38 @@ const EventLoad = () => {
|
||||||
|
|
||||||
const hasAttendedEvent = attendees.totalItems > 0;
|
const hasAttendedEvent = attendees.totalItems > 0;
|
||||||
|
|
||||||
|
// Store the attendance status in the window object with the event
|
||||||
|
const eventDataId = `event_${event.id}`;
|
||||||
|
if (window[eventDataId]) {
|
||||||
|
window[eventDataId].hasAttended = hasAttendedEvent;
|
||||||
|
}
|
||||||
|
|
||||||
// Update the card UI based on attendance status
|
// Update the card UI based on attendance status
|
||||||
const cardElement = document.getElementById(`event-card-${event.id}`);
|
const cardElement = document.getElementById(`event-card-${event.id}`);
|
||||||
if (cardElement && hasAttendedEvent) {
|
if (cardElement) {
|
||||||
const attendedBadge = cardElement.querySelector('.attended-badge');
|
const attendedBadge = document.getElementById(`attendance-badge-${event.id}`);
|
||||||
if (attendedBadge) {
|
if (attendedBadge && hasAttendedEvent) {
|
||||||
(attendedBadge as HTMLElement).style.display = 'flex';
|
attendedBadge.classList.remove('badge-ghost');
|
||||||
|
attendedBadge.classList.add('badge-success');
|
||||||
|
|
||||||
|
// Update the icon and text
|
||||||
|
const icon = attendedBadge.querySelector('svg');
|
||||||
|
if (icon) {
|
||||||
|
icon.setAttribute('icon', 'heroicons:check-circle');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the text content
|
||||||
|
attendedBadge.textContent = '';
|
||||||
|
|
||||||
|
// Recreate the icon
|
||||||
|
const iconElement = document.createElement('span');
|
||||||
|
iconElement.className = 'h-3 w-3';
|
||||||
|
iconElement.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10zm-.997-6l7.07-7.071l-1.414-1.414l-5.656 5.657l-2.829-2.829l-1.414 1.414L11.003 16z"/></svg>';
|
||||||
|
attendedBadge.appendChild(iconElement);
|
||||||
|
|
||||||
|
// Add the text
|
||||||
|
const textNode = document.createTextNode(' Attended');
|
||||||
|
attendedBadge.appendChild(textNode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -176,59 +216,94 @@ const EventLoad = () => {
|
||||||
const endDate = new Date(event.end_date);
|
const endDate = new Date(event.end_date);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const isPastEvent = endDate < now;
|
const isPastEvent = endDate < now;
|
||||||
|
const isExpanded = expandedDescriptions.has(event.id);
|
||||||
|
const description = event.event_description || "No description available";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id={`event-card-${event.id}`} key={event.id} className="card bg-base-200 dark:bg-gray-800/90 shadow-lg hover:shadow-xl transition-all duration-300 relative overflow-hidden border border-base-300 dark:border-gray-700">
|
<div id={`event-card-${event.id}`} key={event.id} className="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-300 relative overflow-hidden">
|
||||||
<div className="card-body p-3 sm:p-4">
|
<div className="card-body p-4">
|
||||||
<div className="flex flex-col h-full">
|
{/* Event Header */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex justify-between items-start mb-2">
|
||||||
<div className="flex-1">
|
<h3 className="card-title text-base sm:text-lg font-semibold line-clamp-2">{event.event_name}</h3>
|
||||||
<h3 className="card-title text-base sm:text-lg font-semibold mb-1 line-clamp-2 text-gray-800 dark:text-gray-100">{event.event_name}</h3>
|
<div className="badge badge-primary badge-sm flex-shrink-0">{event.points_to_reward} pts</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm text-gray-600 dark:text-gray-300">
|
</div>
|
||||||
<div className="badge badge-primary badge-sm">{event.points_to_reward} pts</div>
|
|
||||||
<div className="text-xs sm:text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{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 Description */}
|
||||||
{event.event_description || "No description available"}
|
<div className="mb-3">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-auto pt-2">
|
{/* Event Details */}
|
||||||
{event.files && event.files.length > 0 && (
|
<div className="grid grid-cols-1 gap-1 mb-3 text-xs sm:text-sm">
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => window.openDetailsModal(event as ExtendedEvent)}
|
<Icon icon="heroicons:calendar" className="h-3.5 w-3.5 text-primary" />
|
||||||
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"
|
<span>
|
||||||
>
|
{startDate.toLocaleDateString("en-US", {
|
||||||
<Icon icon="heroicons:document-duplicate" className="h-3 w-3 sm:h-4 sm:w-4" />
|
weekday: "short",
|
||||||
Files ({event.files.length})
|
month: "short",
|
||||||
</button>
|
day: "numeric",
|
||||||
)}
|
})}
|
||||||
{isPastEvent && (
|
</span>
|
||||||
<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 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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -480,9 +555,9 @@ const EventLoad = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Ongoing Events */}
|
{/* Ongoing Events */}
|
||||||
<div className="card bg-base-100 dark:bg-gray-900/90 shadow-xl border border-base-200 dark:border-gray-700 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
|
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
|
||||||
<div className="card-body p-4 sm:p-6">
|
<div className="card-body p-4 sm:p-6">
|
||||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">Ongoing Events</h3>
|
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Ongoing Events</h3>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||||
{[...Array(3)].map((_, i) => (
|
{[...Array(3)].map((_, i) => (
|
||||||
<div key={`ongoing-skeleton-${i}`}>{createSkeletonCard()}</div>
|
<div key={`ongoing-skeleton-${i}`}>{createSkeletonCard()}</div>
|
||||||
|
@ -492,9 +567,9 @@ const EventLoad = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upcoming Events */}
|
{/* Upcoming Events */}
|
||||||
<div className="card bg-base-100 dark:bg-gray-900/90 shadow-xl border border-base-200 dark:border-gray-700 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
|
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
|
||||||
<div className="card-body p-4 sm:p-6">
|
<div className="card-body p-4 sm:p-6">
|
||||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">Upcoming Events</h3>
|
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Upcoming Events</h3>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||||
{[...Array(3)].map((_, i) => (
|
{[...Array(3)].map((_, i) => (
|
||||||
<div key={`upcoming-skeleton-${i}`}>{createSkeletonCard()}</div>
|
<div key={`upcoming-skeleton-${i}`}>{createSkeletonCard()}</div>
|
||||||
|
@ -504,9 +579,9 @@ const EventLoad = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Past Events */}
|
{/* Past Events */}
|
||||||
<div className="card bg-base-100 dark:bg-gray-900/90 shadow-xl border border-base-200 dark:border-gray-700 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mx-4 sm:mx-6">
|
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mx-4 sm:mx-6">
|
||||||
<div className="card-body p-4 sm:p-6">
|
<div className="card-body p-4 sm:p-6">
|
||||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">Past Events</h3>
|
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Past Events</h3>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||||
{[...Array(3)].map((_, i) => (
|
{[...Array(3)].map((_, i) => (
|
||||||
<div key={`past-skeleton-${i}`}>{createSkeletonCard()}</div>
|
<div key={`past-skeleton-${i}`}>{createSkeletonCard()}</div>
|
||||||
|
@ -525,14 +600,14 @@ const EventLoad = () => {
|
||||||
<>
|
<>
|
||||||
{/* No Events Message */}
|
{/* No Events Message */}
|
||||||
{noEvents && (
|
{noEvents && (
|
||||||
<div className="card bg-base-100 dark:bg-gray-900/90 shadow-xl border border-base-200 dark:border-gray-700 mx-4 sm:mx-6 p-8">
|
<div className="card bg-base-100 shadow-xl border border-base-200 mx-4 sm:mx-6 p-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Icon icon="heroicons:calendar" className="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" />
|
<Icon icon="heroicons:calendar" className="w-16 h-16 mx-auto text-base-content/30 mb-4" />
|
||||||
<h3 className="text-xl font-bold mb-2 text-gray-800 dark:text-gray-100">No Events Found</h3>
|
<h3 className="text-xl font-bold mb-2">No Events Found</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
<p className="text-base-content/70 mb-4">
|
||||||
There are currently no events to display. This could be due to:
|
There are currently no events to display. This could be due to:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc text-left max-w-md mx-auto text-gray-600 dark:text-gray-300 mb-6">
|
<ul className="list-disc text-left max-w-md mx-auto text-base-content/70 mb-6">
|
||||||
<li className="mb-1">No events have been published yet</li>
|
<li className="mb-1">No events have been published yet</li>
|
||||||
<li className="mb-1">There might be a connection issue with the event database</li>
|
<li className="mb-1">There might be a connection issue with the event database</li>
|
||||||
<li className="mb-1">The events data might be temporarily unavailable</li>
|
<li className="mb-1">The events data might be temporarily unavailable</li>
|
||||||
|
@ -560,9 +635,9 @@ const EventLoad = () => {
|
||||||
|
|
||||||
{/* Ongoing Events */}
|
{/* Ongoing Events */}
|
||||||
{events.ongoing.length > 0 && (
|
{events.ongoing.length > 0 && (
|
||||||
<div className="card bg-base-100 dark:bg-gray-900/90 shadow-xl border border-base-200 dark:border-gray-700 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
|
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
|
||||||
<div className="card-body p-4 sm:p-6">
|
<div className="card-body p-4 sm:p-6">
|
||||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">Ongoing Events</h3>
|
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Ongoing Events</h3>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||||
{events.ongoing.map(renderEventCard)}
|
{events.ongoing.map(renderEventCard)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -572,9 +647,9 @@ const EventLoad = () => {
|
||||||
|
|
||||||
{/* Upcoming Events */}
|
{/* Upcoming Events */}
|
||||||
{events.upcoming.length > 0 && (
|
{events.upcoming.length > 0 && (
|
||||||
<div className="card bg-base-100 dark:bg-gray-900/90 shadow-xl border border-base-200 dark:border-gray-700 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
|
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
|
||||||
<div className="card-body p-4 sm:p-6">
|
<div className="card-body p-4 sm:p-6">
|
||||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">Upcoming Events</h3>
|
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Upcoming Events</h3>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||||
{events.upcoming.map(renderEventCard)}
|
{events.upcoming.map(renderEventCard)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -584,9 +659,9 @@ const EventLoad = () => {
|
||||||
|
|
||||||
{/* Past Events */}
|
{/* Past Events */}
|
||||||
{events.past.length > 0 && (
|
{events.past.length > 0 && (
|
||||||
<div className="card bg-base-100 dark:bg-gray-900/90 shadow-xl border border-base-200 dark:border-gray-700 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mx-4 sm:mx-6">
|
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mx-4 sm:mx-6">
|
||||||
<div className="card-body p-4 sm:p-6">
|
<div className="card-body p-4 sm:p-6">
|
||||||
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4 text-gray-800 dark:text-gray-100">Past Events</h3>
|
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Past Events</h3>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||||
{events.past.map(renderEventCard)}
|
{events.past.map(renderEventCard)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Get } from '../../../scripts/pocketbase/Get';
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
|
import type { LimitedUser } from '../../../schemas/pocketbase/schema';
|
||||||
|
|
||||||
interface LeaderboardStats {
|
interface LeaderboardStats {
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
|
@ -54,34 +56,50 @@ export default function LeaderboardStats() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// Get all users without sorting - we'll sort on client side
|
// Get all users without sorting - we'll sort on client side
|
||||||
const response = await get.getList('limitedUser', 1, 500, '', '', {
|
const response = await get.getList(Collections.LIMITED_USERS, 1, 500, '', '', {
|
||||||
fields: ['id', 'name', 'points']
|
fields: ['id', 'name', 'points']
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Parse points from JSON string and convert to number
|
||||||
|
const processedUsers = response.items.map((user: Partial<LimitedUser>) => {
|
||||||
|
let pointsValue = 0;
|
||||||
|
try {
|
||||||
|
if (user.points) {
|
||||||
|
// Parse the JSON string to get the points value
|
||||||
|
const pointsData = JSON.parse(user.points);
|
||||||
|
pointsValue = typeof pointsData === 'number' ? pointsData : 0;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing points data:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
parsedPoints: pointsValue
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Filter out users with no points for the leaderboard stats
|
// Filter out users with no points for the leaderboard stats
|
||||||
const leaderboardUsers = response.items
|
const leaderboardUsers = processedUsers
|
||||||
.filter((user: any) =>
|
.filter(user => user.parsedPoints > 0)
|
||||||
user.points !== undefined &&
|
|
||||||
user.points !== null &&
|
|
||||||
user.points > 0
|
|
||||||
)
|
|
||||||
// Sort by points descending
|
// Sort by points descending
|
||||||
.sort((a: any, b: any) => b.points - a.points);
|
.sort((a, b) => b.parsedPoints - a.parsedPoints);
|
||||||
|
|
||||||
const totalUsers = leaderboardUsers.length;
|
const totalUsers = leaderboardUsers.length;
|
||||||
const totalPoints = leaderboardUsers.reduce((sum: number, user: any) => sum + (user.points || 0), 0);
|
const totalPoints = leaderboardUsers.reduce((sum: number, user) => sum + user.parsedPoints, 0);
|
||||||
const topScore = leaderboardUsers.length > 0 ? leaderboardUsers[0].points : 0;
|
const topScore = leaderboardUsers.length > 0 ? leaderboardUsers[0].parsedPoints : 0;
|
||||||
|
|
||||||
// Find current user's points and rank - BUT don't filter by points > 0 for the current user
|
// Find current user's points and rank - BUT don't filter by points > 0 for the current user
|
||||||
let yourPoints = 0;
|
let yourPoints = 0;
|
||||||
let yourRank = null;
|
let yourRank = null;
|
||||||
|
|
||||||
if (isAuthenticated && currentUserId) {
|
if (isAuthenticated && currentUserId) {
|
||||||
// Look for the current user in ALL users, not just those with points > 0
|
// Look for the current user in ALL processed users, not just those with points > 0
|
||||||
const currentUser = response.items.find((user: any) => user.id === currentUserId);
|
const currentUser = processedUsers.find(user => user.id === currentUserId);
|
||||||
|
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
yourPoints = currentUser.points || 0;
|
yourPoints = currentUser.parsedPoints || 0;
|
||||||
|
|
||||||
// Only calculate rank if user has points
|
// Only calculate rank if user has points
|
||||||
if (yourPoints > 0) {
|
if (yourPoints > 0) {
|
||||||
|
@ -119,15 +137,15 @@ export default function LeaderboardStats() {
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchStats();
|
fetchStats();
|
||||||
}, [isAuthenticated, currentUserId]);
|
}, [get, isAuthenticated, currentUserId]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3, 4].map((i) => (
|
||||||
<div key={i} className="h-24 bg-base-200 dark:bg-gray-800/50 animate-pulse rounded-xl">
|
<div key={i} className="h-24 bg-gray-100/50 dark:bg-gray-800/50 animate-pulse rounded-xl">
|
||||||
<div className="h-4 w-24 bg-base-300 dark:bg-gray-700 rounded mb-2 mt-4 mx-4"></div>
|
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded mb-2 mt-4 mx-4"></div>
|
||||||
<div className="h-8 w-16 bg-base-300 dark:bg-gray-700 rounded mx-4"></div>
|
<div className="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded mx-4"></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -136,27 +154,27 @@ export default function LeaderboardStats() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="p-6 bg-base-100 dark:bg-gray-800/90 rounded-xl shadow-sm border border-base-200 dark:border-gray-700">
|
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Total Members</div>
|
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Members</div>
|
||||||
<div className="mt-2 text-3xl font-bold text-gray-800 dark:text-white">{stats.totalUsers}</div>
|
<div className="mt-2 text-3xl font-bold text-gray-800 dark:text-white">{stats.totalUsers}</div>
|
||||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">In the leaderboard</div>
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">In the leaderboard</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-base-100 dark:bg-gray-800/90 rounded-xl shadow-sm border border-base-200 dark:border-gray-700">
|
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Total Points</div>
|
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Points</div>
|
||||||
<div className="mt-2 text-3xl font-bold text-gray-800 dark:text-white">{stats.totalPoints}</div>
|
<div className="mt-2 text-3xl font-bold text-gray-800 dark:text-white">{stats.totalPoints}</div>
|
||||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Earned by all members</div>
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Earned by all members</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-base-100 dark:bg-gray-800/90 rounded-xl shadow-sm border border-base-200 dark:border-gray-700">
|
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Top Score</div>
|
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Top Score</div>
|
||||||
<div className="mt-2 text-3xl font-bold text-primary dark:text-primary">{stats.topScore}</div>
|
<div className="mt-2 text-3xl font-bold text-indigo-600 dark:text-indigo-400">{stats.topScore}</div>
|
||||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Highest individual points</div>
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Highest individual points</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-base-100 dark:bg-gray-800/90 rounded-xl shadow-sm border border-base-200 dark:border-gray-700">
|
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Your Score</div>
|
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Your Score</div>
|
||||||
<div className="mt-2 text-3xl font-bold text-primary dark:text-primary">
|
<div className="mt-2 text-3xl font-bold text-indigo-600 dark:text-indigo-400">
|
||||||
{isAuthenticated ? stats.yourPoints : '-'}
|
{isAuthenticated ? stats.yourPoints : '-'}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Get } from '../../../scripts/pocketbase/Get';
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
import type { User } from '../../../schemas/pocketbase/schema';
|
import type { User, LimitedUser } from '../../../schemas/pocketbase/schema';
|
||||||
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
|
|
||||||
interface LeaderboardUser {
|
interface LeaderboardUser {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -63,21 +64,44 @@ export default function LeaderboardTable() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// Fetch users without sorting - we'll sort on client side
|
// Fetch users without sorting - we'll sort on client side
|
||||||
const response = await get.getList('limitedUser', 1, 100, '', '', {
|
const response = await get.getList(Collections.LIMITED_USERS, 1, 100, '', '', {
|
||||||
fields: ['id', 'name', 'points', 'avatar', 'major']
|
fields: ['id', 'name', 'points', 'avatar', 'major']
|
||||||
});
|
});
|
||||||
|
|
||||||
// First get the current user separately so we can include them even if they have 0 points
|
// First get the current user separately so we can include them even if they have 0 points
|
||||||
let currentUserData = null;
|
let currentUserData = null;
|
||||||
if (isAuthenticated && currentUserId) {
|
if (isAuthenticated && currentUserId) {
|
||||||
currentUserData = response.items.find((user: Partial<User>) => user.id === currentUserId);
|
currentUserData = response.items.find((user: Partial<LimitedUser>) => user.id === currentUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse points from JSON string and convert to number
|
||||||
|
const processedUsers = response.items.map((user: any) => {
|
||||||
|
let pointsValue = 0;
|
||||||
|
try {
|
||||||
|
if (user.points) {
|
||||||
|
// Parse the JSON string to get the points value
|
||||||
|
const pointsData = JSON.parse(user.points);
|
||||||
|
pointsValue = typeof pointsData === 'number' ? pointsData : 0;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing points data:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
major: user.major,
|
||||||
|
avatar: user.avatar, // Include avatar if it exists
|
||||||
|
points: user.points,
|
||||||
|
parsedPoints: pointsValue
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Filter and map to our leaderboard user format, and sort client-side
|
// Filter and map to our leaderboard user format, and sort client-side
|
||||||
let leaderboardUsers = response.items
|
let leaderboardUsers = processedUsers
|
||||||
.filter((user: Partial<User>) => user.points !== undefined && user.points !== null && user.points > 0)
|
.filter(user => user.parsedPoints > 0)
|
||||||
.sort((a: Partial<User>, b: Partial<User>) => (b.points || 0) - (a.points || 0))
|
.sort((a, b) => (b.parsedPoints || 0) - (a.parsedPoints || 0))
|
||||||
.map((user: Partial<User>, index: number) => {
|
.map((user, index: number) => {
|
||||||
// Check if this is the current user
|
// Check if this is the current user
|
||||||
if (isAuthenticated && user.id === currentUserId) {
|
if (isAuthenticated && user.id === currentUserId) {
|
||||||
setCurrentUserRank(index + 1);
|
setCurrentUserRank(index + 1);
|
||||||
|
@ -86,7 +110,7 @@ export default function LeaderboardTable() {
|
||||||
return {
|
return {
|
||||||
id: user.id || '',
|
id: user.id || '',
|
||||||
name: user.name || 'Anonymous User',
|
name: user.name || 'Anonymous User',
|
||||||
points: user.points || 0,
|
points: user.parsedPoints,
|
||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
major: user.major
|
major: user.major
|
||||||
};
|
};
|
||||||
|
@ -94,16 +118,20 @@ export default function LeaderboardTable() {
|
||||||
|
|
||||||
// Include current user even if they have 0 points,
|
// Include current user even if they have 0 points,
|
||||||
// but don't include in ranking if they have no points
|
// but don't include in ranking if they have no points
|
||||||
if (isAuthenticated && currentUserData &&
|
if (isAuthenticated && currentUserId) {
|
||||||
!leaderboardUsers.some(user => user.id === currentUserId)) {
|
// Find current user in processed users
|
||||||
// User isn't already in the list (has 0 points)
|
const currentUserProcessed = processedUsers.find(user => user.id === currentUserId);
|
||||||
leaderboardUsers.push({
|
|
||||||
id: currentUserData.id || '',
|
// If current user exists and isn't already in the leaderboard (has 0 points)
|
||||||
name: currentUserData.name || 'Anonymous User',
|
if (currentUserProcessed && !leaderboardUsers.some(user => user.id === currentUserId)) {
|
||||||
points: currentUserData.points || 0,
|
leaderboardUsers.push({
|
||||||
avatar: currentUserData.avatar,
|
id: currentUserProcessed.id || '',
|
||||||
major: currentUserData.major
|
name: currentUserProcessed.name || 'Anonymous User',
|
||||||
});
|
points: currentUserProcessed.parsedPoints || 0,
|
||||||
|
avatar: currentUserProcessed.avatar,
|
||||||
|
major: currentUserProcessed.major
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setUsers(leaderboardUsers);
|
setUsers(leaderboardUsers);
|
||||||
|
@ -117,7 +145,7 @@ export default function LeaderboardTable() {
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchLeaderboard();
|
fetchLeaderboard();
|
||||||
}, [isAuthenticated, currentUserId]);
|
}, [get, isAuthenticated, currentUserId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchQuery.trim() === '') {
|
if (searchQuery.trim() === '') {
|
||||||
|
@ -184,37 +212,37 @@ export default function LeaderboardTable() {
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name or major..."
|
placeholder="Search by name or major..."
|
||||||
className="w-full pl-10 pr-4 py-2 border border-base-300 dark:border-gray-700 rounded-lg
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg
|
||||||
bg-base-100 dark:bg-gray-800/90 text-gray-700 dark:text-gray-200 focus:outline-none
|
bg-white/90 dark:bg-gray-800/90 text-gray-700 dark:text-gray-200 focus:outline-none
|
||||||
focus:ring-2 focus:ring-primary focus:border-transparent shadow-sm"
|
focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Leaderboard table */}
|
{/* Leaderboard table */}
|
||||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm">
|
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead className="bg-base-200 dark:bg-gray-800/80">
|
<thead className="bg-gray-50/80 dark:bg-gray-800/80">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" className="w-16 px-6 py-3 text-center text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
<th scope="col" className="w-16 px-6 py-3 text-center text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||||
Rank
|
Rank
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||||
User
|
User
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="w-24 px-6 py-3 text-center text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
<th scope="col" className="w-24 px-6 py-3 text-center text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||||
Points
|
Points
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-base-100 dark:bg-gray-900/90 divide-y divide-gray-200 dark:divide-gray-800">
|
<tbody className="bg-white/90 dark:bg-gray-900/90 divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
{currentUsers.map((user, index) => {
|
{currentUsers.map((user, index) => {
|
||||||
const actualRank = user.points > 0 ? indexOfFirstUser + index + 1 : null;
|
const actualRank = user.points > 0 ? indexOfFirstUser + index + 1 : null;
|
||||||
const isCurrentUser = user.id === currentUserId;
|
const isCurrentUser = user.id === currentUserId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={user.id} className={isCurrentUser ? 'bg-primary/10 dark:bg-primary/20' : ''}>
|
<tr key={user.id} className={isCurrentUser ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||||
{actualRank ? (
|
{actualRank ? (
|
||||||
actualRank <= 3 ? (
|
actualRank <= 3 ? (
|
||||||
|
@ -233,7 +261,7 @@ export default function LeaderboardTable() {
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0 h-10 w-10">
|
<div className="flex-shrink-0 h-10 w-10">
|
||||||
<div className="w-10 h-10 rounded-full bg-base-300 dark:bg-gray-700 flex items-center justify-center overflow-hidden relative">
|
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden relative">
|
||||||
{user.avatar ? (
|
{user.avatar ? (
|
||||||
<img className="h-10 w-10 rounded-full" src={user.avatar} alt={user.name} />
|
<img className="h-10 w-10 rounded-full" src={user.avatar} alt={user.name} />
|
||||||
) : (
|
) : (
|
||||||
|
@ -255,7 +283,7 @@ export default function LeaderboardTable() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-center font-bold text-primary dark:text-primary">
|
<td className="px-6 py-4 whitespace-nowrap text-center font-bold text-indigo-600 dark:text-indigo-400">
|
||||||
{user.points}
|
{user.points}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -270,8 +298,8 @@ export default function LeaderboardTable() {
|
||||||
<div className="flex justify-center mt-6">
|
<div className="flex justify-center mt-6">
|
||||||
<nav className="flex items-center">
|
<nav className="flex items-center">
|
||||||
<button
|
<button
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-base-300 dark:border-gray-700
|
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 dark:border-gray-700
|
||||||
bg-base-100 dark:bg-gray-800/90 text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-base-200 dark:hover:bg-gray-700 shadow-sm"
|
bg-white/90 dark:bg-gray-800/90 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
onClick={() => paginate(Math.max(1, currentPage - 1))}
|
onClick={() => paginate(Math.max(1, currentPage - 1))}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
>
|
>
|
||||||
|
@ -284,11 +312,11 @@ export default function LeaderboardTable() {
|
||||||
{Array.from({ length: totalPages }, (_, i) => (
|
{Array.from({ length: totalPages }, (_, i) => (
|
||||||
<button
|
<button
|
||||||
key={i + 1}
|
key={i + 1}
|
||||||
className={`relative inline-flex items-center px-4 py-2 border border-base-300 dark:border-gray-700
|
className={`relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-700
|
||||||
bg-base-100 dark:bg-gray-800/90 text-sm font-medium ${currentPage === i + 1
|
bg-white/90 dark:bg-gray-800/90 text-sm font-medium ${currentPage === i + 1
|
||||||
? 'text-primary dark:text-primary border-primary dark:border-primary z-10 font-bold'
|
? 'text-indigo-600 dark:text-indigo-400 border-indigo-500 dark:border-indigo-500 z-10'
|
||||||
: 'text-gray-700 dark:text-gray-300 hover:bg-base-200 dark:hover:bg-gray-700'
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
} shadow-sm`}
|
}`}
|
||||||
onClick={() => paginate(i + 1)}
|
onClick={() => paginate(i + 1)}
|
||||||
>
|
>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
|
@ -296,8 +324,8 @@ export default function LeaderboardTable() {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-base-300 dark:border-gray-700
|
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 dark:border-gray-700
|
||||||
bg-base-100 dark:bg-gray-800/90 text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-base-200 dark:hover:bg-gray-700 shadow-sm"
|
bg-white/90 dark:bg-gray-800/90 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
onClick={() => paginate(Math.min(totalPages, currentPage + 1))}
|
onClick={() => paginate(Math.min(totalPages, currentPage + 1))}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
>
|
>
|
||||||
|
@ -312,9 +340,9 @@ export default function LeaderboardTable() {
|
||||||
|
|
||||||
{/* Show current user rank if not in current page */}
|
{/* Show current user rank if not in current page */}
|
||||||
{isAuthenticated && currentUserRank && !currentUsers.some(user => user.id === currentUserId) && (
|
{isAuthenticated && currentUserRank && !currentUsers.some(user => user.id === currentUserId) && (
|
||||||
<div className="mt-4 p-3 bg-base-200 dark:bg-gray-800/80 border border-base-300 dark:border-gray-700 rounded-lg shadow-sm">
|
<div className="mt-4 p-3 bg-gray-50/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
<p className="text-center text-sm text-gray-700 dark:text-gray-300">
|
<p className="text-center text-sm text-gray-700 dark:text-gray-300">
|
||||||
Your rank: <span className="font-bold text-primary dark:text-primary">#{currentUserRank}</span>
|
Your rank: <span className="font-bold text-indigo-600 dark:text-indigo-400">#{currentUserRank}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -323,7 +351,7 @@ export default function LeaderboardTable() {
|
||||||
{isAuthenticated && currentUserId &&
|
{isAuthenticated && currentUserId &&
|
||||||
!currentUserRank &&
|
!currentUserRank &&
|
||||||
currentUsers.some(user => user.id === currentUserId) && (
|
currentUsers.some(user => user.id === currentUserId) && (
|
||||||
<div className="mt-4 p-3 bg-base-200 dark:bg-gray-800/80 border border-base-300 dark:border-gray-700 rounded-lg shadow-sm">
|
<div className="mt-4 p-3 bg-gray-50/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
<p className="text-center text-sm text-gray-700 dark:text-gray-300">
|
<p className="text-center text-sm text-gray-700 dark:text-gray-300">
|
||||||
Participate in events to earn points and get ranked!
|
Participate in events to earn points and get ranked!
|
||||||
</p>
|
</p>
|
||||||
|
|
11
src/components/dashboard/OfficerManagement.astro
Normal file
11
src/components/dashboard/OfficerManagement.astro
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
import OfficerManagementComponent from "./OfficerManagement/OfficerManagement";
|
||||||
|
---
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="text-2xl font-bold mb-6">Officer Management</h2>
|
||||||
|
|
||||||
|
<div class="bg-gray-800 shadow-md rounded-lg p-6">
|
||||||
|
<OfficerManagementComponent client:load />
|
||||||
|
</div>
|
||||||
|
</section>
|
2483
src/components/dashboard/OfficerManagement/OfficerManagement.tsx
Normal file
2483
src/components/dashboard/OfficerManagement/OfficerManagement.tsx
Normal file
File diff suppressed because it is too large
Load diff
89
src/components/dashboard/Officer_EmailManagement.astro
Normal file
89
src/components/dashboard/Officer_EmailManagement.astro
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
---
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
import EmailRequestSettings from "./SettingsSection/EmailRequestSettings";
|
||||||
|
|
||||||
|
// Import environment variables for debugging if needed
|
||||||
|
const logtoApiEndpoint = import.meta.env.LOGTO_API_ENDPOINT || "";
|
||||||
|
---
|
||||||
|
<div id="officer-email-section" class="">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-2xl font-bold">IEEE Email Management</h2>
|
||||||
|
<p class="opacity-70">Manage your official IEEE UCSD email address</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IEEE Email Management Card -->
|
||||||
|
<div
|
||||||
|
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
|
||||||
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center justify-center p-3 rounded-full bg-primary text-primary-foreground"
|
||||||
|
>
|
||||||
|
<Icon name="heroicons:envelope" class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
IEEE Email Address
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm opacity-70 mb-4">
|
||||||
|
Request and manage your official IEEE UCSD email address. This email can be used for official IEEE communications and professional purposes.
|
||||||
|
</p>
|
||||||
|
<div class="h-px w-full bg-border my-4"></div>
|
||||||
|
<EmailRequestSettings client:load />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email Guidelines Card -->
|
||||||
|
<div
|
||||||
|
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
|
||||||
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center justify-center p-3 rounded-full bg-info text-info-foreground"
|
||||||
|
>
|
||||||
|
<Icon name="heroicons:information-circle" class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
Email Usage Guidelines
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-4 text-sm">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<Icon name="heroicons:information-circle" class="h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold">Officer Email Access</h4>
|
||||||
|
<p>IEEE email addresses are only available to active IEEE UCSD officers. Your officer status is automatically verified when you request an email.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h4 class="font-semibold">Acceptable Use:</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 opacity-80">
|
||||||
|
<li>Official IEEE UCSD communications</li>
|
||||||
|
<li>Professional networking related to IEEE activities</li>
|
||||||
|
<li>Event coordination and planning</li>
|
||||||
|
<li>Communications with sponsors and external partners</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h4 class="font-semibold">Email Features:</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 opacity-80">
|
||||||
|
<li>Webmail access at <a href="https://mail.ieeeucsd.org" target="_blank" rel="noopener noreferrer" class="link link-primary">https://mail.ieeeucsd.org</a></li>
|
||||||
|
<li>IMAP/SMTP support for email clients</li>
|
||||||
|
<li>5GB storage space</li>
|
||||||
|
<li>Professional @ieeeucsd.org domain</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h4 class="font-semibold">Important Notes:</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 opacity-80">
|
||||||
|
<li>Your email username is based on your personal email address</li>
|
||||||
|
<li>Passwords can be reset through this interface</li>
|
||||||
|
<li>Email access may be revoked when officer status changes</li>
|
||||||
|
<li>Contact the webmaster for any technical issues</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -891,27 +891,27 @@ const currentPage = eventResponse.page;
|
||||||
let start: Date, end: Date;
|
let start: Date, end: Date;
|
||||||
|
|
||||||
// Determine quarter (0-based months: 0-11)
|
// Determine quarter (0-based months: 0-11)
|
||||||
// Q1: Sept-Dec (8-11)
|
// Fall: Sept-Dec (8-11)
|
||||||
// Q2: Jan-Mar (0-2)
|
// Winter: Jan-Mar (0-2)
|
||||||
// Q3: Mar-Jun (2-5)
|
// Spring: Apr-Jun (3-5)
|
||||||
// Q4: Jun-Sept (5-8)
|
// Summer: Jul-Sept (6-8)
|
||||||
|
|
||||||
if (month >= 8) {
|
if (month >= 8) {
|
||||||
// Q1: Sept-Dec
|
// Fall: Sept-Dec
|
||||||
start = new Date(year, 8, 1);
|
start = new Date(year, 8, 1);
|
||||||
end = new Date(year, 11, 31);
|
end = new Date(year, 11, 31);
|
||||||
} else if (month < 2) {
|
} else if (month >= 0 && month < 3) {
|
||||||
// Q2: Jan-Mar
|
// Winter: Jan-Mar
|
||||||
start = new Date(year, 0, 1);
|
start = new Date(year, 0, 1);
|
||||||
end = new Date(year, 2, 31);
|
end = new Date(year, 2, 31);
|
||||||
} else if (month < 5) {
|
} else if (month >= 3 && month < 6) {
|
||||||
// Q3: Mar-Jun
|
// Spring: Apr-Jun
|
||||||
start = new Date(year, 2, 1);
|
start = new Date(year, 3, 1);
|
||||||
end = new Date(year, 5, 30);
|
end = new Date(year, 5, 30);
|
||||||
} else {
|
} else {
|
||||||
// Q4: Jun-Sept
|
// Summer: Jul-Sept
|
||||||
start = new Date(year, 5, 1);
|
start = new Date(year, 6, 1);
|
||||||
end = new Date(year, 8, 0); // End on Aug 31
|
end = new Date(year, 8, 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { start, end };
|
return { start, end };
|
||||||
|
@ -924,14 +924,14 @@ const currentPage = eventResponse.page;
|
||||||
if (month >= 8) {
|
if (month >= 8) {
|
||||||
// Sept-Dec
|
// Sept-Dec
|
||||||
return "Fall";
|
return "Fall";
|
||||||
} else if (month < 2) {
|
} else if (month >= 0 && month < 3) {
|
||||||
// Jan-Mar
|
// Jan-Mar
|
||||||
return "Winter";
|
return "Winter";
|
||||||
} else if (month < 5) {
|
} else if (month >= 3 && month < 6) {
|
||||||
// Mar-Jun
|
// Apr-Jun
|
||||||
return "Spring";
|
return "Spring";
|
||||||
} else {
|
} else {
|
||||||
// Jun-Sept
|
// Jul-Sept
|
||||||
return "Summer";
|
return "Summer";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -979,13 +979,13 @@ const currentPage = eventResponse.page;
|
||||||
isInQuarter = month >= 8 && month <= 11; // Sept-Dec
|
isInQuarter = month >= 8 && month <= 11; // Sept-Dec
|
||||||
break;
|
break;
|
||||||
case "winter":
|
case "winter":
|
||||||
isInQuarter = month >= 0 && month <= 2; // Jan-Mar
|
isInQuarter = month >= 0 && month < 3; // Jan-Mar (0-2)
|
||||||
break;
|
break;
|
||||||
case "spring":
|
case "spring":
|
||||||
isInQuarter = month >= 2 && month <= 5; // Mar-Jun
|
isInQuarter = month >= 3 && month < 6; // Apr-Jun (3-5)
|
||||||
break;
|
break;
|
||||||
case "summer":
|
case "summer":
|
||||||
isInQuarter = month >= 5 && month <= 8; // Jun-Sept
|
isInQuarter = month >= 6 && month < 9; // Jul-Sept (6-8)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (isInQuarter) {
|
if (isInQuarter) {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||||
import { Update } from "../../../scripts/pocketbase/Update";
|
import { Update } from "../../../scripts/pocketbase/Update";
|
||||||
import { FileManager } from "../../../scripts/pocketbase/FileManager";
|
import { FileManager } from "../../../scripts/pocketbase/FileManager";
|
||||||
import { SendLog } from "../../../scripts/pocketbase/SendLog";
|
import { SendLog } from "../../../scripts/pocketbase/SendLog";
|
||||||
|
import { Realtime } from "../../../scripts/pocketbase/Realtime";
|
||||||
import FilePreview from "../universal/FilePreview";
|
import FilePreview from "../universal/FilePreview";
|
||||||
import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase";
|
import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase";
|
||||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||||
|
@ -132,6 +133,28 @@ const EventForm = memo(({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Event Type */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Event Type</span>
|
||||||
|
<span className="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="editEventType"
|
||||||
|
className="select select-bordered"
|
||||||
|
value={event?.event_type || "other"}
|
||||||
|
onChange={(e) => handleChange('event_type', e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="social">Social</option>
|
||||||
|
<option value="technical">Technical</option>
|
||||||
|
<option value="outreach">Outreach</option>
|
||||||
|
<option value="professional">Professional</option>
|
||||||
|
<option value="workshop">Projects</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Points to Reward */}
|
{/* Points to Reward */}
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
@ -142,7 +165,7 @@ const EventForm = memo(({
|
||||||
type="number"
|
type="number"
|
||||||
name="editEventPoints"
|
name="editEventPoints"
|
||||||
className="input input-bordered"
|
className="input input-bordered"
|
||||||
value={event?.points_to_reward || 0}
|
value={event?.points_to_reward || ""}
|
||||||
onChange={(e) => handleChange('points_to_reward', Number(e.target.value))}
|
onChange={(e) => handleChange('points_to_reward', Number(e.target.value))}
|
||||||
min="0"
|
min="0"
|
||||||
required
|
required
|
||||||
|
@ -240,7 +263,15 @@ const EventForm = memo(({
|
||||||
// Show error for rejected files
|
// Show error for rejected files
|
||||||
if (rejectedFiles.length > 0) {
|
if (rejectedFiles.length > 0) {
|
||||||
const errorMessage = `The following files were not added:\n${rejectedFiles.map(f => `${f.name}: ${f.reason}`).join('\n')}`;
|
const errorMessage = `The following files were not added:\n${rejectedFiles.map(f => `${f.name}: ${f.reason}`).join('\n')}`;
|
||||||
toast.error(errorMessage);
|
// Use toast with custom styling to ensure visibility above modal
|
||||||
|
toast.error(errorMessage, {
|
||||||
|
duration: 5000,
|
||||||
|
style: {
|
||||||
|
zIndex: 9999, // Ensure it's above the modal
|
||||||
|
maxWidth: '500px',
|
||||||
|
whiteSpace: 'pre-line' // Preserve line breaks
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedFiles(newFiles);
|
setSelectedFiles(newFiles);
|
||||||
|
@ -293,6 +324,31 @@ const EventForm = memo(({
|
||||||
>
|
>
|
||||||
<Icon icon="heroicons:eye" className="h-4 w-4" />
|
<Icon icon="heroicons:eye" className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
onClick={async () => {
|
||||||
|
if (event?.id) {
|
||||||
|
try {
|
||||||
|
// Get file URL with token for protected files
|
||||||
|
const url = await fileManager.getFileUrlWithToken(
|
||||||
|
"events",
|
||||||
|
event.id,
|
||||||
|
filename,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open file in new tab
|
||||||
|
window.open(url, '_blank');
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to open file:", error);
|
||||||
|
toast.error("Failed to open file. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:arrow-top-right-on-square" className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
<div className="text-error">
|
<div className="text-error">
|
||||||
{filesToDelete.has(filename) ? (
|
{filesToDelete.has(filename) ? (
|
||||||
<button
|
<button
|
||||||
|
@ -401,6 +457,7 @@ interface EventChanges {
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
published?: boolean;
|
published?: boolean;
|
||||||
has_food?: boolean;
|
has_food?: boolean;
|
||||||
|
event_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileChanges {
|
interface FileChanges {
|
||||||
|
@ -487,7 +544,8 @@ class ChangeTracker {
|
||||||
'start_date',
|
'start_date',
|
||||||
'end_date',
|
'end_date',
|
||||||
'published',
|
'published',
|
||||||
'has_food'
|
'has_food',
|
||||||
|
'event_type'
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
|
@ -550,11 +608,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
event_code: "",
|
event_code: "",
|
||||||
location: "",
|
location: "",
|
||||||
files: [],
|
files: [],
|
||||||
points_to_reward: 0,
|
points_to_reward: null as unknown as number,
|
||||||
start_date: "",
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
published: false,
|
published: false,
|
||||||
has_food: false
|
has_food: false,
|
||||||
|
event_type: "other"
|
||||||
});
|
});
|
||||||
|
|
||||||
const [previewUrl, setPreviewUrl] = useState("");
|
const [previewUrl, setPreviewUrl] = useState("");
|
||||||
|
@ -571,7 +630,8 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
auth: Authentication.getInstance(),
|
auth: Authentication.getInstance(),
|
||||||
update: Update.getInstance(),
|
update: Update.getInstance(),
|
||||||
fileManager: FileManager.getInstance(),
|
fileManager: FileManager.getInstance(),
|
||||||
sendLog: SendLog.getInstance()
|
sendLog: SendLog.getInstance(),
|
||||||
|
realtime: Realtime.getInstance()
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
// Handle field changes
|
// Handle field changes
|
||||||
|
@ -590,17 +650,35 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
const initializeEventData = useCallback(async (eventId: string) => {
|
const initializeEventData = useCallback(async (eventId: string) => {
|
||||||
try {
|
try {
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
|
// Show loading state
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
// Clear cache to ensure fresh data
|
// Clear cache to ensure fresh data
|
||||||
const dataSync = DataSyncService.getInstance();
|
const dataSync = DataSyncService.getInstance();
|
||||||
await dataSync.clearCache();
|
await dataSync.clearCache();
|
||||||
|
|
||||||
// Fetch fresh event data
|
// Fetch fresh event data with expanded relations if needed
|
||||||
const eventData = await services.get.getOne<Event>(Collections.EVENTS, eventId);
|
const eventData = await services.get.getOne<Event>(
|
||||||
|
Collections.EVENTS,
|
||||||
|
eventId,
|
||||||
|
{
|
||||||
|
disableAutoCancellation: true,
|
||||||
|
// Add any fields to expand if needed
|
||||||
|
// expand: ['related_field1', 'related_field2']
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!eventData) {
|
if (!eventData) {
|
||||||
throw new Error("Event not found");
|
throw new Error("Event not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log successful data fetch
|
||||||
|
await services.sendLog.send(
|
||||||
|
"view",
|
||||||
|
"event",
|
||||||
|
`Loaded event data: ${eventData.event_name} (${eventId})`
|
||||||
|
);
|
||||||
|
|
||||||
// Ensure dates are properly formatted for datetime-local input
|
// Ensure dates are properly formatted for datetime-local input
|
||||||
if (eventData.start_date) {
|
if (eventData.start_date) {
|
||||||
// Convert to Date object first to ensure proper formatting
|
// Convert to Date object first to ensure proper formatting
|
||||||
|
@ -624,15 +702,44 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
event_code: eventData.event_code || '',
|
event_code: eventData.event_code || '',
|
||||||
location: eventData.location || '',
|
location: eventData.location || '',
|
||||||
files: eventData.files || [],
|
files: eventData.files || [],
|
||||||
points_to_reward: eventData.points_to_reward || 0,
|
points_to_reward: eventData.points_to_reward || null as unknown as number,
|
||||||
start_date: eventData.start_date || '',
|
start_date: eventData.start_date || '',
|
||||||
end_date: eventData.end_date || '',
|
end_date: eventData.end_date || '',
|
||||||
published: eventData.published || false,
|
published: eventData.published || false,
|
||||||
has_food: eventData.has_food || false
|
has_food: eventData.has_food || false,
|
||||||
|
event_type: eventData.event_type || 'other'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set up realtime subscription for this event
|
||||||
|
const realtime = services.realtime;
|
||||||
|
|
||||||
|
// Define the RealtimeEvent type for proper typing
|
||||||
|
interface RealtimeEvent<T> {
|
||||||
|
action: "create" | "update" | "delete";
|
||||||
|
record: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionId = realtime.subscribeToRecord<RealtimeEvent<Event>>(
|
||||||
|
Collections.EVENTS,
|
||||||
|
eventId,
|
||||||
|
(data) => {
|
||||||
|
if (data.action === "update") {
|
||||||
|
// Auto-refresh data when event is updated elsewhere
|
||||||
|
initializeEventData(eventId);
|
||||||
|
toast.success("Event data has been updated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store subscription ID for cleanup
|
||||||
|
(window as any).eventSubscriptionId = subscriptionId;
|
||||||
|
|
||||||
// console.log("Event data loaded successfully:", eventData);
|
// console.log("Event data loaded successfully:", eventData);
|
||||||
} else {
|
} else {
|
||||||
|
// Creating a new event
|
||||||
|
const now = new Date();
|
||||||
|
const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
|
||||||
|
|
||||||
setEvent({
|
setEvent({
|
||||||
id: '',
|
id: '',
|
||||||
created: '',
|
created: '',
|
||||||
|
@ -642,11 +749,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
event_code: '',
|
event_code: '',
|
||||||
location: '',
|
location: '',
|
||||||
files: [],
|
files: [],
|
||||||
points_to_reward: 0,
|
points_to_reward: null as unknown as number,
|
||||||
start_date: '',
|
start_date: Get.formatLocalDate(now, false),
|
||||||
end_date: '',
|
end_date: Get.formatLocalDate(oneHourLater, false),
|
||||||
published: false,
|
published: false,
|
||||||
has_food: false
|
has_food: false,
|
||||||
|
event_type: "other"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setSelectedFiles(new Map());
|
setSelectedFiles(new Map());
|
||||||
|
@ -656,8 +764,10 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to initialize event data:", error);
|
console.error("Failed to initialize event data:", error);
|
||||||
toast.error("Failed to load event data. Please try again.");
|
toast.error("Failed to load event data. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [services.get]);
|
}, [services]);
|
||||||
|
|
||||||
// Expose initializeEventData to window
|
// Expose initializeEventData to window
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -698,6 +808,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up realtime subscription if it exists
|
||||||
|
if ((window as any).eventSubscriptionId) {
|
||||||
|
services.realtime.unsubscribe((window as any).eventSubscriptionId);
|
||||||
|
delete (window as any).eventSubscriptionId;
|
||||||
|
}
|
||||||
|
|
||||||
setEvent({
|
setEvent({
|
||||||
id: "",
|
id: "",
|
||||||
created: "",
|
created: "",
|
||||||
|
@ -707,11 +823,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
event_code: "",
|
event_code: "",
|
||||||
location: "",
|
location: "",
|
||||||
files: [],
|
files: [],
|
||||||
points_to_reward: 0,
|
points_to_reward: null as unknown as number,
|
||||||
start_date: "",
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
published: false,
|
published: false,
|
||||||
has_food: false
|
has_food: false,
|
||||||
|
event_type: "other"
|
||||||
});
|
});
|
||||||
setSelectedFiles(new Map());
|
setSelectedFiles(new Map());
|
||||||
setFilesToDelete(new Set());
|
setFilesToDelete(new Set());
|
||||||
|
@ -719,12 +836,24 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
setPreviewUrl("");
|
setPreviewUrl("");
|
||||||
setPreviewFilename("");
|
setPreviewFilename("");
|
||||||
|
|
||||||
|
// Clear file input element to reset filename display
|
||||||
|
const fileInput = document.querySelector('input[name="editEventFiles"]') as HTMLInputElement;
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
|
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
|
||||||
if (modal) modal.close();
|
if (modal) modal.close();
|
||||||
}, [hasUnsavedChanges, isSubmitting]);
|
}, [hasUnsavedChanges, isSubmitting, services.realtime]);
|
||||||
|
|
||||||
// Function to close modal after saving (without confirmation)
|
// Function to close modal after saving (without confirmation)
|
||||||
const closeModalAfterSave = useCallback(() => {
|
const closeModalAfterSave = useCallback(() => {
|
||||||
|
// Clean up realtime subscription if it exists
|
||||||
|
if ((window as any).eventSubscriptionId) {
|
||||||
|
services.realtime.unsubscribe((window as any).eventSubscriptionId);
|
||||||
|
delete (window as any).eventSubscriptionId;
|
||||||
|
}
|
||||||
|
|
||||||
setEvent({
|
setEvent({
|
||||||
id: "",
|
id: "",
|
||||||
created: "",
|
created: "",
|
||||||
|
@ -734,11 +863,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
event_code: "",
|
event_code: "",
|
||||||
location: "",
|
location: "",
|
||||||
files: [],
|
files: [],
|
||||||
points_to_reward: 0,
|
points_to_reward: null as unknown as number,
|
||||||
start_date: "",
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
published: false,
|
published: false,
|
||||||
has_food: false
|
has_food: false,
|
||||||
|
event_type: "other"
|
||||||
});
|
});
|
||||||
setSelectedFiles(new Map());
|
setSelectedFiles(new Map());
|
||||||
setFilesToDelete(new Set());
|
setFilesToDelete(new Set());
|
||||||
|
@ -746,9 +876,15 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
setPreviewUrl("");
|
setPreviewUrl("");
|
||||||
setPreviewFilename("");
|
setPreviewFilename("");
|
||||||
|
|
||||||
|
// Reset the file input element to clear the filename display
|
||||||
|
const fileInput = document.querySelector('input[name="editEventFiles"]') as HTMLInputElement;
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
|
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
|
||||||
if (modal) modal.close();
|
if (modal) modal.close();
|
||||||
}, []);
|
}, [services.realtime]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -778,11 +914,12 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
event_code: formData.get("editEventCode") as string,
|
event_code: formData.get("editEventCode") as string,
|
||||||
location: formData.get("editEventLocation") as string,
|
location: formData.get("editEventLocation") as string,
|
||||||
files: event.files || [],
|
files: event.files || [],
|
||||||
points_to_reward: parseInt(formData.get("editEventPoints") as string) || 0,
|
points_to_reward: formData.get("editEventPoints") ? parseInt(formData.get("editEventPoints") as string) : null as unknown as number,
|
||||||
start_date: formData.get("editEventStartDate") as string,
|
start_date: formData.get("editEventStartDate") as string,
|
||||||
end_date: formData.get("editEventEndDate") as string,
|
end_date: formData.get("editEventEndDate") as string,
|
||||||
published: formData.get("editEventPublished") === "on",
|
published: formData.get("editEventPublished") === "on",
|
||||||
has_food: formData.get("editEventHasFood") === "on"
|
has_food: formData.get("editEventHasFood") === "on",
|
||||||
|
event_type: formData.get("editEventType") as string || "other"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Log the update attempt
|
// Log the update attempt
|
||||||
|
|
|
@ -70,6 +70,12 @@ interface ASFundingSectionProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataChange }) => {
|
const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataChange }) => {
|
||||||
|
// Check initial budget status
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (formData.invoiceData?.total) {
|
||||||
|
checkBudgetLimit(formData.invoiceData.total);
|
||||||
|
}
|
||||||
|
}, [formData.expected_attendance]);
|
||||||
const [invoiceFiles, setInvoiceFiles] = useState<File[]>(formData.invoice_files || []);
|
const [invoiceFiles, setInvoiceFiles] = useState<File[]>(formData.invoice_files || []);
|
||||||
const [jsonInput, setJsonInput] = useState<string>('');
|
const [jsonInput, setJsonInput] = useState<string>('');
|
||||||
const [jsonError, setJsonError] = useState<string>('');
|
const [jsonError, setJsonError] = useState<string>('');
|
||||||
|
@ -80,11 +86,26 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
|
||||||
const handleInvoiceFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInvoiceFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
const newFiles = Array.from(e.target.files) as File[];
|
const newFiles = Array.from(e.target.files) as File[];
|
||||||
setInvoiceFiles(newFiles);
|
// Combine existing files with new files instead of replacing
|
||||||
onDataChange({ invoice_files: newFiles });
|
const combinedFiles = [...invoiceFiles, ...newFiles];
|
||||||
|
setInvoiceFiles(combinedFiles);
|
||||||
|
onDataChange({ invoice_files: combinedFiles });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle removing individual files
|
||||||
|
const handleRemoveFile = (indexToRemove: number) => {
|
||||||
|
const updatedFiles = invoiceFiles.filter((_, index) => index !== indexToRemove);
|
||||||
|
setInvoiceFiles(updatedFiles);
|
||||||
|
onDataChange({ invoice_files: updatedFiles });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle clearing all files
|
||||||
|
const handleClearAllFiles = () => {
|
||||||
|
setInvoiceFiles([]);
|
||||||
|
onDataChange({ invoice_files: [] });
|
||||||
|
};
|
||||||
|
|
||||||
// Handle JSON input change
|
// Handle JSON input change
|
||||||
const handleJsonInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleJsonInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setJsonInput(e.target.value);
|
setJsonInput(e.target.value);
|
||||||
|
@ -122,6 +143,19 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate and apply JSON
|
// Validate and apply JSON
|
||||||
|
// Check budget limits and show warning if exceeded
|
||||||
|
const checkBudgetLimit = (total: number) => {
|
||||||
|
const maxBudget = Math.min(formData.expected_attendance * 10, 5000);
|
||||||
|
if (total > maxBudget) {
|
||||||
|
toast.error(`Total amount ($${total.toFixed(2)}) exceeds maximum funding of $${maxBudget.toFixed(2)} for ${formData.expected_attendance} attendees.`, {
|
||||||
|
duration: 4000,
|
||||||
|
position: 'top-center'
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const validateAndApplyJson = () => {
|
const validateAndApplyJson = () => {
|
||||||
try {
|
try {
|
||||||
if (!jsonInput.trim()) {
|
if (!jsonInput.trim()) {
|
||||||
|
@ -181,6 +215,9 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
|
||||||
total: data.total
|
total: data.total
|
||||||
}, null, 2);
|
}, null, 2);
|
||||||
|
|
||||||
|
// Check budget limits and show toast if needed
|
||||||
|
checkBudgetLimit(data.total);
|
||||||
|
|
||||||
// Apply the JSON data to the form
|
// Apply the JSON data to the form
|
||||||
onDataChange({
|
onDataChange({
|
||||||
invoiceData: data,
|
invoiceData: data,
|
||||||
|
@ -212,15 +249,17 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
|
||||||
|
|
||||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
const newFiles = Array.from(e.dataTransfer.files) as File[];
|
const newFiles = Array.from(e.dataTransfer.files) as File[];
|
||||||
setInvoiceFiles(newFiles);
|
// Combine existing files with new files instead of replacing
|
||||||
onDataChange({ invoice_files: newFiles });
|
const combinedFiles = [...invoiceFiles, ...newFiles];
|
||||||
|
setInvoiceFiles(combinedFiles);
|
||||||
|
onDataChange({ invoice_files: combinedFiles });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle invoice data change from the invoice builder
|
// Handle invoice data change from the invoice builder
|
||||||
const handleInvoiceDataChange = (data: InvoiceData) => {
|
const handleInvoiceDataChange = (data: InvoiceData) => {
|
||||||
// Calculate if budget exceeds maximum allowed
|
// Check budget limits and show toast if needed
|
||||||
const maxBudget = Math.min(formData.expected_attendance * 10, 5000);
|
checkBudgetLimit(data.total);
|
||||||
|
|
||||||
onDataChange({
|
onDataChange({
|
||||||
invoiceData: data,
|
invoiceData: data,
|
||||||
|
@ -289,20 +328,44 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
|
||||||
|
|
||||||
{invoiceFiles.length > 0 ? (
|
{invoiceFiles.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<p className="font-medium text-primary">{invoiceFiles.length} file(s) selected:</p>
|
<div className="flex items-center justify-between w-full">
|
||||||
<div className="max-h-24 overflow-y-auto text-left w-full">
|
<p className="font-medium text-primary">{invoiceFiles.length} file(s) selected:</p>
|
||||||
<ul className="list-disc list-inside text-sm">
|
<button
|
||||||
{invoiceFiles.map((file, index) => (
|
type="button"
|
||||||
<li key={index} className="truncate">{file.name}</li>
|
onClick={(e) => {
|
||||||
))}
|
e.stopPropagation();
|
||||||
</ul>
|
handleClearAllFiles();
|
||||||
|
}}
|
||||||
|
className="btn btn-xs btn-outline btn-error"
|
||||||
|
title="Clear all files"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">Click or drag to replace</p>
|
<div className="max-h-32 overflow-y-auto text-left w-full space-y-1">
|
||||||
|
{invoiceFiles.map((file, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between bg-base-100 p-2 rounded">
|
||||||
|
<span className="text-sm truncate flex-1">{file.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveFile(index);
|
||||||
|
}}
|
||||||
|
className="btn btn-xs btn-error ml-2"
|
||||||
|
title="Remove file"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:x-mark" className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">Click or drag to add more files</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="font-medium">Drop your invoice files here or click to browse</p>
|
<p className="font-medium">Drop your invoice files here or click to browse</p>
|
||||||
<p className="text-xs text-gray-500">Supports PDF, JPG, JPEG, PNG</p>
|
<p className="text-xs text-gray-500">Supports PDF, JPG, JPEG, PNG (multiple files allowed)</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -129,7 +129,27 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
|
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
|
||||||
value={formData.start_date_time}
|
value={formData.start_date_time}
|
||||||
onChange={(e) => onDataChange({ start_date_time: e.target.value })}
|
onChange={(e) => {
|
||||||
|
const newStartDateTime = e.target.value;
|
||||||
|
onDataChange({ start_date_time: newStartDateTime });
|
||||||
|
|
||||||
|
// If there's already an end time set, update it to use the new start date
|
||||||
|
if (formData.end_date_time && newStartDateTime) {
|
||||||
|
try {
|
||||||
|
const existingEndDate = new Date(formData.end_date_time);
|
||||||
|
const newStartDate = new Date(newStartDateTime);
|
||||||
|
|
||||||
|
if (!isNaN(existingEndDate.getTime()) && !isNaN(newStartDate.getTime())) {
|
||||||
|
// Keep the same time but update to the new date
|
||||||
|
const updatedEndDate = new Date(newStartDate);
|
||||||
|
updatedEndDate.setHours(existingEndDate.getHours(), existingEndDate.getMinutes(), 0, 0);
|
||||||
|
onDataChange({ end_date_time: updatedEndDate.toISOString() });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating end date when start date changed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
required
|
required
|
||||||
whileHover="hover"
|
whileHover="hover"
|
||||||
variants={inputHoverVariants}
|
variants={inputHoverVariants}
|
||||||
|
@ -155,25 +175,59 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
|
||||||
<motion.input
|
<motion.input
|
||||||
type="time"
|
type="time"
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
value={formData.end_date_time ? new Date(formData.end_date_time).toTimeString().substring(0, 5) : ''}
|
value={formData.end_date_time ? (() => {
|
||||||
|
try {
|
||||||
|
const endDate = new Date(formData.end_date_time);
|
||||||
|
if (isNaN(endDate.getTime())) return '';
|
||||||
|
return endDate.toTimeString().substring(0, 5);
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
})() : ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (formData.start_date_time) {
|
const timeValue = e.target.value;
|
||||||
// Create a new date object from start_date_time
|
if (timeValue && formData.start_date_time) {
|
||||||
const startDate = new Date(formData.start_date_time);
|
try {
|
||||||
// Parse the time value
|
// Create a new date object from start_date_time
|
||||||
const [hours, minutes] = e.target.value.split(':').map(Number);
|
const startDate = new Date(formData.start_date_time);
|
||||||
// Set the hours and minutes on the date
|
if (isNaN(startDate.getTime())) {
|
||||||
startDate.setHours(hours, minutes);
|
console.error('Invalid start date time');
|
||||||
// Update end_date_time with the new time but same date as start
|
return;
|
||||||
onDataChange({ end_date_time: startDate.toISOString() });
|
}
|
||||||
|
|
||||||
|
// 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: '' });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
|
disabled={!formData.start_date_time}
|
||||||
whileHover="hover"
|
whileHover="hover"
|
||||||
variants={inputHoverVariants}
|
variants={inputHoverVariants}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-base-content/60">
|
<p className="text-xs text-base-content/60">
|
||||||
The end time will use the same date as the start date.
|
{!formData.start_date_time
|
||||||
|
? "Please set the start date and time first."
|
||||||
|
: "The end time will use the same date as the start date."
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { FileManager } from '../../../scripts/pocketbase/FileManager';
|
||||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
import { EventRequestStatus } from '../../../schemas/pocketbase';
|
import { EventRequestStatus } from '../../../schemas/pocketbase';
|
||||||
|
import { EmailClient } from '../../../scripts/email/EmailClient';
|
||||||
|
|
||||||
// Form sections
|
// Form sections
|
||||||
import PRSection from './PRSection';
|
import PRSection from './PRSection';
|
||||||
|
@ -69,13 +70,13 @@ export interface EventRequestFormData {
|
||||||
flyer_advertising_start_date: string;
|
flyer_advertising_start_date: string;
|
||||||
flyer_additional_requests: string;
|
flyer_additional_requests: string;
|
||||||
required_logos: string[];
|
required_logos: string[];
|
||||||
other_logos: File[]; // Form uses File objects, schema uses strings
|
other_logos: File[]; // Form uses File objects, schema uses strings - MULTIPLE FILES
|
||||||
advertising_format: string;
|
advertising_format: string;
|
||||||
will_or_have_room_booking: boolean;
|
will_or_have_room_booking: boolean;
|
||||||
expected_attendance: number;
|
expected_attendance: number;
|
||||||
room_booking: File | null;
|
room_booking_files: File[]; // CHANGED: Multiple room booking files instead of single
|
||||||
invoice: File | null;
|
invoice: File | null;
|
||||||
invoice_files: File[];
|
invoice_files: File[]; // MULTIPLE FILES
|
||||||
invoiceData: InvoiceData;
|
invoiceData: InvoiceData;
|
||||||
needs_graphics?: boolean | null;
|
needs_graphics?: boolean | null;
|
||||||
needs_as_funding?: boolean | null;
|
needs_as_funding?: boolean | null;
|
||||||
|
@ -88,7 +89,6 @@ import CustomAlert from '../universal/CustomAlert';
|
||||||
const EventRequestForm: React.FC = () => {
|
const EventRequestForm: React.FC = () => {
|
||||||
const [currentStep, setCurrentStep] = useState<number>(1);
|
const [currentStep, setCurrentStep] = useState<number>(1);
|
||||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Initialize form data
|
// Initialize form data
|
||||||
const [formData, setFormData] = useState<EventRequestFormData>({
|
const [formData, setFormData] = useState<EventRequestFormData>({
|
||||||
|
@ -108,7 +108,7 @@ const EventRequestForm: React.FC = () => {
|
||||||
advertising_format: '',
|
advertising_format: '',
|
||||||
will_or_have_room_booking: false,
|
will_or_have_room_booking: false,
|
||||||
expected_attendance: 0,
|
expected_attendance: 0,
|
||||||
room_booking: null,
|
room_booking_files: [],
|
||||||
as_funding_required: false,
|
as_funding_required: false,
|
||||||
food_drinks_being_served: false,
|
food_drinks_being_served: false,
|
||||||
itemized_invoice: '',
|
itemized_invoice: '',
|
||||||
|
@ -134,9 +134,10 @@ const EventRequestForm: React.FC = () => {
|
||||||
const dataToStore = {
|
const dataToStore = {
|
||||||
...formDataToSave,
|
...formDataToSave,
|
||||||
other_logos: [],
|
other_logos: [],
|
||||||
room_booking: null,
|
room_booking_files: [],
|
||||||
invoice: null,
|
invoice: null,
|
||||||
invoice_files: []
|
invoice_files: [],
|
||||||
|
savedAt: Date.now() // Add timestamp for stale data detection
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('eventRequestFormData', JSON.stringify(dataToStore));
|
localStorage.setItem('eventRequestFormData', JSON.stringify(dataToStore));
|
||||||
|
@ -153,12 +154,27 @@ const EventRequestForm: React.FC = () => {
|
||||||
if (savedData) {
|
if (savedData) {
|
||||||
try {
|
try {
|
||||||
const parsedData = JSON.parse(savedData);
|
const parsedData = JSON.parse(savedData);
|
||||||
setFormData(prevData => ({
|
|
||||||
...prevData,
|
// Check if the saved data is stale (older than 24 hours)
|
||||||
...parsedData
|
const now = Date.now();
|
||||||
}));
|
const savedTime = parsedData.savedAt || 0;
|
||||||
|
const staleThreshold = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
if (now - savedTime > staleThreshold) {
|
||||||
|
// Clear stale data
|
||||||
|
localStorage.removeItem('eventRequestFormData');
|
||||||
|
console.log('Cleared stale form data from localStorage');
|
||||||
|
} else {
|
||||||
|
// Load the saved data
|
||||||
|
setFormData(prevData => ({
|
||||||
|
...prevData,
|
||||||
|
...parsedData
|
||||||
|
}));
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing saved form data:', e);
|
console.error('Error parsing saved form data:', e);
|
||||||
|
// Clear corrupted data
|
||||||
|
localStorage.removeItem('eventRequestFormData');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
@ -176,9 +192,29 @@ const EventRequestForm: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData(prevData => {
|
setFormData(prevData => {
|
||||||
// Save to localStorage
|
|
||||||
const updatedData = { ...prevData, ...sectionData };
|
const updatedData = { ...prevData, ...sectionData };
|
||||||
localStorage.setItem('eventRequestFormData', JSON.stringify(updatedData));
|
|
||||||
|
// Save to localStorage
|
||||||
|
try {
|
||||||
|
const dataToStore = {
|
||||||
|
...updatedData,
|
||||||
|
// Remove file objects before saving to localStorage
|
||||||
|
other_logos: [],
|
||||||
|
room_booking_files: [],
|
||||||
|
invoice: null,
|
||||||
|
invoice_files: [],
|
||||||
|
savedAt: Date.now() // Add timestamp for stale data detection
|
||||||
|
};
|
||||||
|
localStorage.setItem('eventRequestFormData', JSON.stringify(dataToStore));
|
||||||
|
|
||||||
|
// Also update the preview data
|
||||||
|
window.dispatchEvent(new CustomEvent('formDataUpdated', {
|
||||||
|
detail: { formData: updatedData }
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving form data to localStorage:', error);
|
||||||
|
}
|
||||||
|
|
||||||
return updatedData;
|
return updatedData;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -202,7 +238,7 @@ const EventRequestForm: React.FC = () => {
|
||||||
advertising_format: '',
|
advertising_format: '',
|
||||||
will_or_have_room_booking: false,
|
will_or_have_room_booking: false,
|
||||||
expected_attendance: 0,
|
expected_attendance: 0,
|
||||||
room_booking: null, // No room booking by default
|
room_booking_files: [],
|
||||||
as_funding_required: false,
|
as_funding_required: false,
|
||||||
food_drinks_being_served: false,
|
food_drinks_being_served: false,
|
||||||
itemized_invoice: '',
|
itemized_invoice: '',
|
||||||
|
@ -236,7 +272,6 @@ const EventRequestForm: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = Authentication.getInstance();
|
const auth = Authentication.getInstance();
|
||||||
|
@ -267,8 +302,36 @@ const EventRequestForm: React.FC = () => {
|
||||||
requested_user: userId,
|
requested_user: userId,
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
location: formData.location,
|
location: formData.location,
|
||||||
start_date_time: new Date(formData.start_date_time).toISOString(),
|
start_date_time: (() => {
|
||||||
end_date_time: formData.end_date_time ? new Date(formData.end_date_time).toISOString() : new Date(formData.start_date_time).toISOString(),
|
try {
|
||||||
|
const startDate = new Date(formData.start_date_time);
|
||||||
|
if (isNaN(startDate.getTime())) {
|
||||||
|
throw new Error('Invalid start date');
|
||||||
|
}
|
||||||
|
return startDate.toISOString();
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Invalid start date format');
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
end_date_time: (() => {
|
||||||
|
try {
|
||||||
|
if (formData.end_date_time) {
|
||||||
|
const endDate = new Date(formData.end_date_time);
|
||||||
|
if (isNaN(endDate.getTime())) {
|
||||||
|
throw new Error('Invalid end date');
|
||||||
|
}
|
||||||
|
return endDate.toISOString();
|
||||||
|
} else {
|
||||||
|
// Fallback to start date if no end date (should not happen with validation)
|
||||||
|
const startDate = new Date(formData.start_date_time);
|
||||||
|
return startDate.toISOString();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to start date
|
||||||
|
const startDate = new Date(formData.start_date_time);
|
||||||
|
return startDate.toISOString();
|
||||||
|
}
|
||||||
|
})(),
|
||||||
event_description: formData.event_description,
|
event_description: formData.event_description,
|
||||||
flyers_needed: formData.flyers_needed,
|
flyers_needed: formData.flyers_needed,
|
||||||
photography_needed: formData.photography_needed,
|
photography_needed: formData.photography_needed,
|
||||||
|
@ -277,7 +340,14 @@ const EventRequestForm: React.FC = () => {
|
||||||
itemized_invoice: formData.itemized_invoice,
|
itemized_invoice: formData.itemized_invoice,
|
||||||
flyer_type: formData.flyer_type,
|
flyer_type: formData.flyer_type,
|
||||||
other_flyer_type: formData.other_flyer_type,
|
other_flyer_type: formData.other_flyer_type,
|
||||||
flyer_advertising_start_date: formData.flyer_advertising_start_date ? new Date(formData.flyer_advertising_start_date).toISOString() : '',
|
flyer_advertising_start_date: formData.flyer_advertising_start_date ? (() => {
|
||||||
|
try {
|
||||||
|
const advertDate = new Date(formData.flyer_advertising_start_date);
|
||||||
|
return isNaN(advertDate.getTime()) ? '' : advertDate.toISOString();
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
})() : '',
|
||||||
flyer_additional_requests: formData.flyer_additional_requests,
|
flyer_additional_requests: formData.flyer_additional_requests,
|
||||||
required_logos: formData.required_logos,
|
required_logos: formData.required_logos,
|
||||||
advertising_format: formData.advertising_format,
|
advertising_format: formData.advertising_format,
|
||||||
|
@ -302,36 +372,126 @@ const EventRequestForm: React.FC = () => {
|
||||||
// This will send the data to the server
|
// This will send the data to the server
|
||||||
const record = await update.create('event_request', submissionData);
|
const record = await update.create('event_request', submissionData);
|
||||||
|
|
||||||
// Force sync the event requests collection to update IndexedDB
|
// Force sync the event requests collection to update IndexedDB with deletion detection
|
||||||
await dataSync.syncCollection(Collections.EVENT_REQUESTS);
|
await dataSync.syncCollection(Collections.EVENT_REQUESTS, "", "-created", {}, true);
|
||||||
|
|
||||||
// Upload files if they exist
|
console.log('Event request record created:', record.id);
|
||||||
|
|
||||||
|
// Upload files if they exist - handle each file type separately
|
||||||
|
const fileUploadErrors: string[] = [];
|
||||||
|
|
||||||
|
// Upload other logos
|
||||||
if (formData.other_logos.length > 0) {
|
if (formData.other_logos.length > 0) {
|
||||||
await fileManager.uploadFiles('event_request', record.id, 'other_logos', formData.other_logos);
|
try {
|
||||||
|
console.log('Uploading other logos:', formData.other_logos.length, 'files');
|
||||||
|
console.log('Other logos files:', formData.other_logos.map(f => ({ name: f.name, size: f.size, type: f.type })));
|
||||||
|
await fileManager.uploadFiles('event_request', record.id, 'other_logos', formData.other_logos);
|
||||||
|
console.log('Other logos uploaded successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload other logos:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
fileUploadErrors.push(`Failed to upload custom logo files: ${errorMessage}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.room_booking) {
|
// Upload room booking files
|
||||||
await fileManager.uploadFile('event_request', record.id, 'room_booking', formData.room_booking);
|
if (formData.room_booking_files && formData.room_booking_files.length > 0) {
|
||||||
|
try {
|
||||||
|
console.log('Uploading room booking files:', formData.room_booking_files.length, 'files');
|
||||||
|
console.log('Room booking files:', formData.room_booking_files.map(f => ({ name: f.name, size: f.size, type: f.type })));
|
||||||
|
console.log('Using collection:', 'event_request', 'record ID:', record.id, 'field:', 'room_booking');
|
||||||
|
|
||||||
|
// Use the correct field name 'room_booking' instead of 'room_booking_files'
|
||||||
|
await fileManager.uploadFiles('event_request', record.id, 'room_booking', formData.room_booking_files);
|
||||||
|
console.log('Room booking files uploaded successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload room booking files:', error);
|
||||||
|
console.error('Error details:', {
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
collection: 'event_request',
|
||||||
|
recordId: record.id,
|
||||||
|
field: 'room_booking',
|
||||||
|
fileCount: formData.room_booking_files.length
|
||||||
|
});
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
fileUploadErrors.push(`Failed to upload room booking files: ${errorMessage}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload multiple invoice files
|
// Upload invoice files
|
||||||
if (formData.invoice_files && formData.invoice_files.length > 0) {
|
if (formData.invoice_files && formData.invoice_files.length > 0) {
|
||||||
await fileManager.appendFiles('event_request', record.id, 'invoice_files', formData.invoice_files);
|
try {
|
||||||
|
console.log('Uploading invoice files:', formData.invoice_files.length, 'files');
|
||||||
// For backward compatibility, also upload the first file as the main invoice
|
console.log('Invoice files:', formData.invoice_files.map(f => ({ name: f.name, size: f.size, type: f.type })));
|
||||||
if (formData.invoice || formData.invoice_files[0]) {
|
console.log('Using collection:', 'event_request', 'record ID:', record.id, 'field:', 'invoice');
|
||||||
const mainInvoice = formData.invoice || formData.invoice_files[0];
|
|
||||||
await fileManager.uploadFile('event_request', record.id, 'invoice', mainInvoice);
|
// Use the correct field name 'invoice' instead of 'invoice_files'
|
||||||
|
await fileManager.uploadFiles('event_request', record.id, 'invoice', formData.invoice_files);
|
||||||
|
console.log('Invoice files uploaded successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload invoice files:', error);
|
||||||
|
console.error('Error details:', {
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
collection: 'event_request',
|
||||||
|
recordId: record.id,
|
||||||
|
field: 'invoice',
|
||||||
|
fileCount: formData.invoice_files.length
|
||||||
|
});
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
fileUploadErrors.push(`Failed to upload invoice files: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
} else if (formData.invoice) {
|
} else if (formData.invoice) {
|
||||||
await fileManager.uploadFile('event_request', record.id, 'invoice', formData.invoice);
|
try {
|
||||||
|
console.log('Uploading single invoice file:', { name: formData.invoice.name, size: formData.invoice.size, type: formData.invoice.type });
|
||||||
|
await fileManager.uploadFile('event_request', record.id, 'invoice', formData.invoice);
|
||||||
|
console.log('Invoice file uploaded successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload invoice file:', error);
|
||||||
|
console.error('Error details:', {
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
collection: 'event_request',
|
||||||
|
recordId: record.id,
|
||||||
|
field: 'invoice'
|
||||||
|
});
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
fileUploadErrors.push(`Failed to upload invoice file: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show file upload warnings if any occurred
|
||||||
|
if (fileUploadErrors.length > 0) {
|
||||||
|
console.warn('File upload errors:', fileUploadErrors);
|
||||||
|
// Show each file upload error as a separate toast for better UX
|
||||||
|
fileUploadErrors.forEach(error => {
|
||||||
|
toast.error(error, {
|
||||||
|
duration: 6000, // Longer duration for file upload errors
|
||||||
|
position: 'top-right'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Also show a summary toast
|
||||||
|
toast.error(`Event request submitted successfully, but ${fileUploadErrors.length} file upload(s) failed. Please check the errors above and re-upload the files manually.`, {
|
||||||
|
duration: 8000,
|
||||||
|
position: 'top-center'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Keep success toast for form submission since it's a user action
|
||||||
|
toast.success('Event request submitted successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear form data from localStorage
|
// Clear form data from localStorage
|
||||||
localStorage.removeItem('eventRequestFormData');
|
localStorage.removeItem('eventRequestFormData');
|
||||||
|
|
||||||
// Keep success toast for form submission since it's a user action
|
// Send email notification to coordinators (non-blocking)
|
||||||
toast.success('Event request submitted successfully!');
|
try {
|
||||||
|
await EmailClient.notifyEventRequestSubmission(record.id);
|
||||||
|
console.log('Event request notification email sent successfully');
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Failed to send event request notification email:', emailError);
|
||||||
|
// Don't show error to user - email failure shouldn't disrupt the main flow
|
||||||
|
}
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
resetForm();
|
resetForm();
|
||||||
|
@ -344,7 +504,6 @@ const EventRequestForm: React.FC = () => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting event request:', error);
|
console.error('Error submitting event request:', error);
|
||||||
toast.error('Failed to submit event request. Please try again.');
|
toast.error('Failed to submit event request. Please try again.');
|
||||||
setError('Failed to submit event request. Please try again.');
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
@ -407,11 +566,47 @@ const EventRequestForm: React.FC = () => {
|
||||||
if (!formData.start_date_time || formData.start_date_time.trim() === '') {
|
if (!formData.start_date_time || formData.start_date_time.trim() === '') {
|
||||||
errors.push('Event start date and time is required');
|
errors.push('Event start date and time is required');
|
||||||
valid = false;
|
valid = false;
|
||||||
|
} else {
|
||||||
|
// Validate start date format
|
||||||
|
try {
|
||||||
|
const startDate = new Date(formData.start_date_time);
|
||||||
|
if (isNaN(startDate.getTime())) {
|
||||||
|
errors.push('Invalid start date and time format');
|
||||||
|
valid = false;
|
||||||
|
} else {
|
||||||
|
// Check if start date is in the future
|
||||||
|
const now = new Date();
|
||||||
|
if (startDate <= now) {
|
||||||
|
errors.push('Event start date must be in the future');
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push('Invalid start date and time format');
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.end_date_time) {
|
if (!formData.end_date_time || formData.end_date_time.trim() === '') {
|
||||||
errors.push('Event end time is required');
|
errors.push('Event end time is required');
|
||||||
valid = false;
|
valid = false;
|
||||||
|
} else if (formData.start_date_time) {
|
||||||
|
// Validate end date format and logic
|
||||||
|
try {
|
||||||
|
const startDate = new Date(formData.start_date_time);
|
||||||
|
const endDate = new Date(formData.end_date_time);
|
||||||
|
|
||||||
|
if (isNaN(endDate.getTime())) {
|
||||||
|
errors.push('Invalid end date and time format');
|
||||||
|
valid = false;
|
||||||
|
} else if (!isNaN(startDate.getTime()) && endDate <= startDate) {
|
||||||
|
errors.push('Event end time must be after the start time');
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push('Invalid end date and time format');
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.location || formData.location.trim() === '') {
|
if (!formData.location || formData.location.trim() === '') {
|
||||||
|
@ -419,13 +614,14 @@ const EventRequestForm: React.FC = () => {
|
||||||
valid = false;
|
valid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.will_or_have_room_booking === undefined) {
|
if (formData.will_or_have_room_booking === undefined || formData.will_or_have_room_booking === null) {
|
||||||
errors.push('Room booking status is required');
|
errors.push('Room booking status is required');
|
||||||
valid = false;
|
valid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
setError(errors[0]);
|
// Show the first error as a toast instead of setting error state
|
||||||
|
toast.error(errors[0]);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -445,9 +641,9 @@ const EventRequestForm: React.FC = () => {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only require room booking file if will_or_have_room_booking is true
|
// REQUIRED: Room booking files if will_or_have_room_booking is true
|
||||||
if (formData.will_or_have_room_booking && !formData.room_booking) {
|
if (formData.will_or_have_room_booking && formData.room_booking_files.length === 0) {
|
||||||
toast.error('Please upload your room booking confirmation');
|
toast.error('Room booking files are required when you need a room booking');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -467,10 +663,16 @@ const EventRequestForm: React.FC = () => {
|
||||||
|
|
||||||
// Validate AS Funding Section
|
// Validate AS Funding Section
|
||||||
const validateASFundingSection = () => {
|
const validateASFundingSection = () => {
|
||||||
if (formData.as_funding_required) {
|
if (formData.as_funding_required || formData.needs_as_funding) {
|
||||||
|
// REQUIRED: Invoice files if AS funding is needed
|
||||||
|
if (!formData.invoice_files || formData.invoice_files.length === 0) {
|
||||||
|
toast.error('Invoice files are required when requesting AS funding');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if invoice data is present and has items
|
// Check if invoice data is present and has items
|
||||||
if (!formData.invoiceData || !formData.invoiceData.items || formData.invoiceData.items.length === 0) {
|
if (!formData.invoiceData || !formData.invoiceData.items || formData.invoiceData.items.length === 0) {
|
||||||
setError('Please add at least one item to your invoice');
|
toast.error('Please add at least one item to your invoice');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -482,7 +684,7 @@ const EventRequestForm: React.FC = () => {
|
||||||
// Check if the budget exceeds the maximum allowed ($5000 cap regardless of attendance)
|
// Check if the budget exceeds the maximum allowed ($5000 cap regardless of attendance)
|
||||||
const maxBudget = Math.min(formData.expected_attendance * 10, 5000);
|
const maxBudget = Math.min(formData.expected_attendance * 10, 5000);
|
||||||
if (totalBudget > maxBudget) {
|
if (totalBudget > maxBudget) {
|
||||||
setError(`Your budget (${totalBudget.toFixed(2)} dollars) exceeds the maximum allowed (${maxBudget} dollars). The absolute maximum is $5,000.`);
|
toast.error(`Your budget ($${totalBudget.toFixed(2)}) exceeds the maximum allowed ($${maxBudget}). The absolute maximum is $5,000.`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -813,21 +1015,6 @@ const EventRequestForm: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
{error && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<CustomAlert
|
|
||||||
type="error"
|
|
||||||
title="Error"
|
|
||||||
message={error}
|
|
||||||
icon="heroicons:exclamation-triangle"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress indicator */}
|
{/* Progress indicator */}
|
||||||
<div className="w-full mb-6">
|
<div className="w-full mb-6">
|
||||||
<div className="flex justify-between mb-2">
|
<div className="flex justify-between mb-2">
|
||||||
|
|
|
@ -4,6 +4,7 @@ import type { EventRequestFormData } from './EventRequestForm';
|
||||||
import type { EventRequest } from '../../../schemas/pocketbase';
|
import type { EventRequest } from '../../../schemas/pocketbase';
|
||||||
import { FlyerTypes, LogoOptions } from '../../../schemas/pocketbase';
|
import { FlyerTypes, LogoOptions } from '../../../schemas/pocketbase';
|
||||||
import CustomAlert from '../universal/CustomAlert';
|
import CustomAlert from '../universal/CustomAlert';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
|
||||||
// Enhanced animation variants
|
// Enhanced animation variants
|
||||||
const containerVariants = {
|
const containerVariants = {
|
||||||
|
@ -122,11 +123,26 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
const handleLogoFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleLogoFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
const newFiles = Array.from(e.target.files) as File[];
|
const newFiles = Array.from(e.target.files) as File[];
|
||||||
setOtherLogoFiles(newFiles);
|
// Combine existing files with new files instead of replacing
|
||||||
onDataChange({ other_logos: newFiles });
|
const combinedFiles = [...otherLogoFiles, ...newFiles];
|
||||||
|
setOtherLogoFiles(combinedFiles);
|
||||||
|
onDataChange({ other_logos: combinedFiles });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle removing individual files
|
||||||
|
const handleRemoveLogoFile = (indexToRemove: number) => {
|
||||||
|
const updatedFiles = otherLogoFiles.filter((_, index) => index !== indexToRemove);
|
||||||
|
setOtherLogoFiles(updatedFiles);
|
||||||
|
onDataChange({ other_logos: updatedFiles });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle clearing all files
|
||||||
|
const handleClearAllLogoFiles = () => {
|
||||||
|
setOtherLogoFiles([]);
|
||||||
|
onDataChange({ other_logos: [] });
|
||||||
|
};
|
||||||
|
|
||||||
// Handle drag events for file upload
|
// Handle drag events for file upload
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -144,8 +160,10 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
|
|
||||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
const newFiles = Array.from(e.dataTransfer.files) as File[];
|
const newFiles = Array.from(e.dataTransfer.files) as File[];
|
||||||
setOtherLogoFiles(newFiles);
|
// Combine existing files with new files instead of replacing
|
||||||
onDataChange({ other_logos: newFiles });
|
const combinedFiles = [...otherLogoFiles, ...newFiles];
|
||||||
|
setOtherLogoFiles(combinedFiles);
|
||||||
|
onDataChange({ other_logos: combinedFiles });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -349,20 +367,44 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
|
|
||||||
{otherLogoFiles.length > 0 ? (
|
{otherLogoFiles.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<p className="font-medium text-primary">{otherLogoFiles.length} file(s) selected:</p>
|
<div className="flex items-center justify-between w-full">
|
||||||
<div className="max-h-24 overflow-y-auto text-left w-full">
|
<p className="font-medium text-primary">{otherLogoFiles.length} file(s) selected:</p>
|
||||||
<ul className="list-disc list-inside text-sm">
|
<button
|
||||||
{otherLogoFiles.map((file, index) => (
|
type="button"
|
||||||
<li key={index} className="truncate">{file.name}</li>
|
onClick={(e) => {
|
||||||
))}
|
e.stopPropagation();
|
||||||
</ul>
|
handleClearAllLogoFiles();
|
||||||
|
}}
|
||||||
|
className="btn btn-xs btn-outline btn-error"
|
||||||
|
title="Clear all files"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">Click or drag to replace</p>
|
<div className="max-h-32 overflow-y-auto text-left w-full space-y-1">
|
||||||
|
{otherLogoFiles.map((file, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between bg-base-100 p-2 rounded">
|
||||||
|
<span className="text-sm truncate flex-1">{file.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveLogoFile(index);
|
||||||
|
}}
|
||||||
|
className="btn btn-xs btn-error ml-2"
|
||||||
|
title="Remove file"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:x-mark" className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">Click or drag to add more files</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="font-medium">Drop your logo files here or click to browse</p>
|
<p className="font-medium">Drop your logo files here or click to browse</p>
|
||||||
<p className="text-xs text-gray-500">Please upload transparent logo files (PNG preferred)</p>
|
<p className="text-xs text-gray-500">Please upload transparent logo files (PNG preferred, multiple files allowed)</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,6 +4,7 @@ import type { EventRequestFormData } from './EventRequestForm';
|
||||||
import type { EventRequest } from '../../../schemas/pocketbase';
|
import type { EventRequest } from '../../../schemas/pocketbase';
|
||||||
import CustomAlert from '../universal/CustomAlert';
|
import CustomAlert from '../universal/CustomAlert';
|
||||||
import FilePreview from '../universal/FilePreview';
|
import FilePreview from '../universal/FilePreview';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
|
||||||
// Enhanced animation variants
|
// Enhanced animation variants
|
||||||
const containerVariants = {
|
const containerVariants = {
|
||||||
|
@ -69,11 +70,12 @@ interface TAPFormSectionProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange }) => {
|
const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange }) => {
|
||||||
const [roomBookingFile, setRoomBookingFile] = useState<File | null>(formData.room_booking);
|
const [roomBookingFiles, setRoomBookingFiles] = useState<File[]>(formData.room_booking_files || []);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [fileError, setFileError] = useState<string | null>(null);
|
const [fileError, setFileError] = useState<string | null>(null);
|
||||||
const [showFilePreview, setShowFilePreview] = useState(false);
|
const [showFilePreview, setShowFilePreview] = useState(false);
|
||||||
const [filePreviewUrl, setFilePreviewUrl] = useState<string | null>(null);
|
const [filePreviewUrl, setFilePreviewUrl] = useState<string | null>(null);
|
||||||
|
const [selectedPreviewFile, setSelectedPreviewFile] = useState<File | null>(null);
|
||||||
|
|
||||||
// Add style tag for hidden arrows
|
// Add style tag for hidden arrows
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -89,27 +91,58 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
// Handle room booking file upload with size limit
|
// Handle room booking file upload with size limit
|
||||||
const handleRoomBookingFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleRoomBookingFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
const file = e.target.files[0];
|
const newFiles = Array.from(e.target.files) as File[];
|
||||||
|
|
||||||
// Check file size - 1MB limit
|
// Check file sizes - 1MB limit for each file
|
||||||
if (file.size > 1024 * 1024) {
|
const oversizedFiles = newFiles.filter(file => file.size > 1024 * 1024);
|
||||||
setFileError("Room booking file size must be under 1MB");
|
if (oversizedFiles.length > 0) {
|
||||||
|
setFileError(`${oversizedFiles.length} file(s) exceed 1MB limit`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFileError(null);
|
setFileError(null);
|
||||||
setRoomBookingFile(file);
|
// Combine existing files with new files instead of replacing
|
||||||
onDataChange({ room_booking: file });
|
const combinedFiles = [...roomBookingFiles, ...newFiles];
|
||||||
|
setRoomBookingFiles(combinedFiles);
|
||||||
|
onDataChange({ room_booking_files: combinedFiles });
|
||||||
|
|
||||||
// Create preview URL
|
// Create preview URL for the first new file
|
||||||
if (filePreviewUrl) {
|
if (filePreviewUrl) {
|
||||||
URL.revokeObjectURL(filePreviewUrl);
|
URL.revokeObjectURL(filePreviewUrl);
|
||||||
}
|
}
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(newFiles[0]);
|
||||||
setFilePreviewUrl(url);
|
setFilePreviewUrl(url);
|
||||||
|
setSelectedPreviewFile(newFiles[0]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle removing individual files
|
||||||
|
const handleRemoveFile = (indexToRemove: number) => {
|
||||||
|
const updatedFiles = roomBookingFiles.filter((_, index) => index !== indexToRemove);
|
||||||
|
setRoomBookingFiles(updatedFiles);
|
||||||
|
onDataChange({ room_booking_files: updatedFiles });
|
||||||
|
|
||||||
|
// Clear preview if we removed the previewed file
|
||||||
|
if (selectedPreviewFile && updatedFiles.length === 0) {
|
||||||
|
if (filePreviewUrl) {
|
||||||
|
URL.revokeObjectURL(filePreviewUrl);
|
||||||
|
setFilePreviewUrl(null);
|
||||||
|
}
|
||||||
|
setSelectedPreviewFile(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle clearing all files
|
||||||
|
const handleClearAllFiles = () => {
|
||||||
|
setRoomBookingFiles([]);
|
||||||
|
onDataChange({ room_booking_files: [] });
|
||||||
|
if (filePreviewUrl) {
|
||||||
|
URL.revokeObjectURL(filePreviewUrl);
|
||||||
|
setFilePreviewUrl(null);
|
||||||
|
}
|
||||||
|
setSelectedPreviewFile(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Handle drag events for file upload
|
// Handle drag events for file upload
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -126,24 +159,28 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
|
||||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
const file = e.dataTransfer.files[0];
|
const newFiles = Array.from(e.dataTransfer.files) as File[];
|
||||||
|
|
||||||
// Check file size - 1MB limit
|
// Check file sizes - 1MB limit for each file
|
||||||
if (file.size > 1024 * 1024) {
|
const oversizedFiles = newFiles.filter(file => file.size > 1024 * 1024);
|
||||||
setFileError("Room booking file size must be under 1MB");
|
if (oversizedFiles.length > 0) {
|
||||||
|
setFileError(`${oversizedFiles.length} file(s) exceed 1MB limit`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFileError(null);
|
setFileError(null);
|
||||||
setRoomBookingFile(file);
|
// Combine existing files with new files instead of replacing
|
||||||
onDataChange({ room_booking: file });
|
const combinedFiles = [...roomBookingFiles, ...newFiles];
|
||||||
|
setRoomBookingFiles(combinedFiles);
|
||||||
|
onDataChange({ room_booking_files: combinedFiles });
|
||||||
|
|
||||||
// Create preview URL
|
// Create preview URL for the first new file
|
||||||
if (filePreviewUrl) {
|
if (filePreviewUrl) {
|
||||||
URL.revokeObjectURL(filePreviewUrl);
|
URL.revokeObjectURL(filePreviewUrl);
|
||||||
}
|
}
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(newFiles[0]);
|
||||||
setFilePreviewUrl(url);
|
setFilePreviewUrl(url);
|
||||||
|
setSelectedPreviewFile(newFiles[0]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -262,6 +299,11 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
<span className="label-text font-medium text-lg">Room Booking Confirmation</span>
|
<span className="label-text font-medium text-lg">Room Booking Confirmation</span>
|
||||||
{formData.will_or_have_room_booking && <span className="label-text-alt text-error">*</span>}
|
{formData.will_or_have_room_booking && <span className="label-text-alt text-error">*</span>}
|
||||||
</label>
|
</label>
|
||||||
|
{formData.will_or_have_room_booking && (
|
||||||
|
<p className="text-sm text-gray-500 mb-3">
|
||||||
|
<strong>Required:</strong> Upload your room booking confirmation document.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{fileError && (
|
{fileError && (
|
||||||
<div className="mt-2 mb-2">
|
<div className="mt-2 mb-2">
|
||||||
|
@ -292,6 +334,7 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleRoomBookingFileChange}
|
onChange={handleRoomBookingFileChange}
|
||||||
accept=".pdf,.png,.jpg,.jpeg"
|
accept=".pdf,.png,.jpg,.jpeg"
|
||||||
|
multiple
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3">
|
<div className="flex flex-col items-center justify-center gap-3">
|
||||||
|
@ -304,16 +347,46 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
</svg>
|
</svg>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{roomBookingFile ? (
|
{roomBookingFiles.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<p className="font-medium text-primary">File selected:</p>
|
<div className="flex items-center justify-between w-full">
|
||||||
<p className="text-sm">{roomBookingFile.name}</p>
|
<p className="font-medium text-primary">{roomBookingFiles.length} file(s) selected:</p>
|
||||||
<p className="text-xs text-gray-500">Click or drag to replace (Max size: 1MB)</p>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClearAllFiles();
|
||||||
|
}}
|
||||||
|
className="btn btn-xs btn-outline btn-error"
|
||||||
|
title="Clear all files"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-32 overflow-y-auto text-left w-full space-y-1">
|
||||||
|
{roomBookingFiles.map((file, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between bg-base-100 p-2 rounded">
|
||||||
|
<span className="text-sm truncate flex-1">{file.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveFile(index);
|
||||||
|
}}
|
||||||
|
className="btn btn-xs btn-error ml-2"
|
||||||
|
title="Remove file"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:x-mark" className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">Click or drag to add more files (Max size: 1MB each)</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="font-medium">Drop your file here or click to browse</p>
|
<p className="font-medium">Drop your files here or click to browse</p>
|
||||||
<p className="text-xs text-gray-500">Accepted formats: PDF, PNG, JPG (Max size: 1MB)</p>
|
<p className="text-xs text-gray-500">Accepted formats: PDF, PNG, JPG (Max size: 1MB each, multiple files allowed)</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -329,20 +402,20 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Preview File Button - Outside the upload area */}
|
{/* Preview File Button - Outside the upload area */}
|
||||||
{formData.will_or_have_room_booking && roomBookingFile && (
|
{formData.will_or_have_room_booking && roomBookingFiles.length > 0 && (
|
||||||
<div className="mt-3 flex justify-end">
|
<div className="mt-3 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
onClick={toggleFilePreview}
|
onClick={toggleFilePreview}
|
||||||
>
|
>
|
||||||
{showFilePreview ? 'Hide Preview' : 'Preview File'}
|
{showFilePreview ? 'Hide Preview' : `Preview Files (${roomBookingFiles.length})`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File Preview Component */}
|
{/* File Preview Component */}
|
||||||
{showFilePreview && filePreviewUrl && roomBookingFile && (
|
{showFilePreview && roomBookingFiles.length > 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
@ -350,7 +423,7 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
className="mt-4 p-4 bg-base-200 rounded-lg"
|
className="mt-4 p-4 bg-base-200 rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="font-medium">File Preview</h3>
|
<h3 className="font-medium">File Preview ({roomBookingFiles.length} files)</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-sm btn-circle"
|
className="btn btn-sm btn-circle"
|
||||||
|
@ -361,7 +434,17 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<FilePreview url={filePreviewUrl} filename={roomBookingFile.name} />
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{roomBookingFiles.map((file, index) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
return (
|
||||||
|
<div key={index} className="border rounded-lg p-2">
|
||||||
|
<p className="text-sm font-medium mb-2 truncate">{file.name}</p>
|
||||||
|
<FilePreview url={url} filename={file.name} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
@ -258,12 +258,14 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use DataSyncService to get data from IndexedDB with forced sync
|
// Use DataSyncService to get data from IndexedDB with forced sync and deletion detection
|
||||||
const updatedRequests = await dataSync.getData<EventRequest>(
|
const updatedRequests = await dataSync.getData<EventRequest>(
|
||||||
Collections.EVENT_REQUESTS,
|
Collections.EVENT_REQUESTS,
|
||||||
true, // Force sync
|
true, // Force sync
|
||||||
`requested_user="${userId}"`,
|
`requested_user="${userId}"`,
|
||||||
'-created'
|
'-created',
|
||||||
|
{}, // expand
|
||||||
|
true // Enable deletion detection for user-specific requests
|
||||||
);
|
);
|
||||||
|
|
||||||
setEventRequests(updatedRequests);
|
setEventRequests(updatedRequests);
|
||||||
|
|
|
@ -53,7 +53,7 @@ try {
|
||||||
"",
|
"",
|
||||||
"-created",
|
"-created",
|
||||||
{
|
{
|
||||||
expand: ["requested_user"],
|
expand: "requested_user",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
@ -308,7 +308,7 @@ try {
|
||||||
Collections.EVENT_REQUESTS,
|
Collections.EVENT_REQUESTS,
|
||||||
"",
|
"",
|
||||||
"-created",
|
"-created",
|
||||||
{ expand: "requested_user" }
|
"requested_user"
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error during initial data sync:", err);
|
console.error("Error during initial data sync:", err);
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
|
||||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
|
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
|
||||||
import { FileManager } from '../../../scripts/pocketbase/FileManager';
|
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
import CustomAlert from '../universal/CustomAlert';
|
import CustomAlert from '../universal/CustomAlert';
|
||||||
|
import UniversalFilePreview from '../universal/FilePreview';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
|
|
||||||
// Extended EventRequest interface with additional properties needed for this component
|
// Extended EventRequest interface with additional properties needed for this component
|
||||||
|
@ -29,10 +28,11 @@ interface ExtendedEventRequest extends SchemaEventRequest {
|
||||||
invoice_files?: string[]; // Array of invoice file IDs
|
invoice_files?: string[]; // Array of invoice file IDs
|
||||||
flyer_files?: string[]; // Add this for PR-related files
|
flyer_files?: string[]; // Add this for PR-related files
|
||||||
files?: string[]; // Generic files field
|
files?: string[]; // Generic files field
|
||||||
room_reservation_needed?: boolean;
|
will_or_have_room_booking?: boolean;
|
||||||
room_reservation_location?: string;
|
room_booking_files?: string[]; // CHANGED: Multiple room booking files instead of single
|
||||||
room_reservation_confirmed?: boolean;
|
room_reservation_needed?: boolean; // Keep for backward compatibility
|
||||||
additional_notes?: string;
|
additional_notes?: string;
|
||||||
|
flyers_completed?: boolean; // Track if flyers have been completed by PR team
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventRequestDetailsProps {
|
interface EventRequestDetailsProps {
|
||||||
|
@ -82,7 +82,7 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
|
||||||
setFileUrl(secureUrl);
|
setFileUrl(secureUrl);
|
||||||
|
|
||||||
// Determine file type from extension
|
// Determine file type from extension
|
||||||
const extension = fileName.split('.').pop()?.toLowerCase() || '';
|
const extension = (typeof fileName === 'string' ? fileName.split('.').pop()?.toLowerCase() : '') || '';
|
||||||
setFileType(extension);
|
setFileType(extension);
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
@ -624,7 +624,7 @@ const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request })
|
||||||
) : (
|
) : (
|
||||||
<Icon icon="mdi:file-document" className="h-8 w-8 text-secondary" />
|
<Icon icon="mdi:file-document" className="h-8 w-8 text-secondary" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-grow">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium truncate" title={fileId}>
|
<p className="font-medium truncate" title={fileId}>
|
||||||
{displayName}
|
{displayName}
|
||||||
</p>
|
</p>
|
||||||
|
@ -712,6 +712,28 @@ const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request })
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Copyable Invoice Format */}
|
||||||
|
<motion.div
|
||||||
|
className="bg-base-100/10 p-5 rounded-lg border border-base-100/10"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="bg-info/20 p-3 rounded-full">
|
||||||
|
<Icon icon="mdi:content-copy" className="h-6 w-6 text-info" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Copyable Format</h3>
|
||||||
|
<p className="text-sm text-gray-400">Copy formatted invoice data for easy sharing</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<CopyableInvoiceFormat invoiceData={invoiceData} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* File Preview Modal */}
|
{/* File Preview Modal */}
|
||||||
<FilePreviewModal
|
<FilePreviewModal
|
||||||
isOpen={isPreviewModalOpen}
|
isOpen={isPreviewModalOpen}
|
||||||
|
@ -725,6 +747,150 @@ const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request })
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Component for copyable invoice format
|
||||||
|
const CopyableInvoiceFormat: React.FC<{ invoiceData: any }> = ({ invoiceData }) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [formattedText, setFormattedText] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!invoiceData) {
|
||||||
|
setFormattedText('No invoice data available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse invoice data if it's a string
|
||||||
|
let parsedInvoice = null;
|
||||||
|
|
||||||
|
if (typeof invoiceData === 'string') {
|
||||||
|
try {
|
||||||
|
parsedInvoice = JSON.parse(invoiceData);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse invoice data string:', e);
|
||||||
|
setFormattedText('Invalid invoice data format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (typeof invoiceData === 'object' && invoiceData !== null) {
|
||||||
|
parsedInvoice = invoiceData;
|
||||||
|
} else {
|
||||||
|
setFormattedText('No structured invoice data available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract items array
|
||||||
|
let items = [];
|
||||||
|
if (parsedInvoice.items && Array.isArray(parsedInvoice.items)) {
|
||||||
|
items = parsedInvoice.items;
|
||||||
|
} else if (Array.isArray(parsedInvoice)) {
|
||||||
|
items = parsedInvoice;
|
||||||
|
} else if (parsedInvoice.items && typeof parsedInvoice.items === 'object') {
|
||||||
|
items = [parsedInvoice.items]; // Wrap single item in array
|
||||||
|
} else {
|
||||||
|
// Try to find any array in the object
|
||||||
|
for (const key in parsedInvoice) {
|
||||||
|
if (Array.isArray(parsedInvoice[key])) {
|
||||||
|
items = parsedInvoice[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we still don't have items, check if the object itself looks like an item
|
||||||
|
if (items.length === 0 && (parsedInvoice.item || parsedInvoice.description || parsedInvoice.name)) {
|
||||||
|
items = [parsedInvoice];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format the items into the required string format
|
||||||
|
const formattedItems = items.map((item: any) => {
|
||||||
|
const quantity = parseFloat(item?.quantity || 1);
|
||||||
|
const itemName = typeof item?.item === 'object'
|
||||||
|
? JSON.stringify(item.item)
|
||||||
|
: (item?.item || item?.description || item?.name || 'N/A');
|
||||||
|
const unitPrice = parseFloat(item?.unit_price || item?.unitPrice || item?.price || 0);
|
||||||
|
|
||||||
|
return `${quantity} ${itemName} x${unitPrice.toFixed(2)} each`;
|
||||||
|
}).join(' | ');
|
||||||
|
|
||||||
|
// Get tax, tip and total
|
||||||
|
const tax = parseFloat(parsedInvoice.tax || parsedInvoice.taxAmount || 0);
|
||||||
|
const tip = parseFloat(parsedInvoice.tip || parsedInvoice.tipAmount || 0);
|
||||||
|
const total = parseFloat(parsedInvoice.total || 0) ||
|
||||||
|
items.reduce((sum: number, item: any) => {
|
||||||
|
const quantity = parseFloat(item?.quantity || 1);
|
||||||
|
const price = parseFloat(item?.unit_price || item?.unitPrice || item?.price || 0);
|
||||||
|
return sum + (quantity * price);
|
||||||
|
}, 0) + tax + tip;
|
||||||
|
|
||||||
|
// Get vendor/location
|
||||||
|
const location = parsedInvoice.vendor || parsedInvoice.location || 'Unknown Vendor';
|
||||||
|
|
||||||
|
// Build the final formatted string
|
||||||
|
let result = formattedItems;
|
||||||
|
|
||||||
|
if (tax > 0) {
|
||||||
|
result += ` | Tax = ${tax.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tip > 0) {
|
||||||
|
result += ` | Tip = ${tip.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += ` | Total = ${total.toFixed(2)} from ${location}`;
|
||||||
|
|
||||||
|
setFormattedText(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting invoice data:', error);
|
||||||
|
setFormattedText('Error formatting invoice data');
|
||||||
|
}
|
||||||
|
}, [invoiceData]);
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
navigator.clipboard.writeText(formattedText)
|
||||||
|
.then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
toast.success('Copied to clipboard!');
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to copy text: ', err);
|
||||||
|
toast.error('Failed to copy text');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-base-200/30 p-4 rounded-lg">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<label className="text-sm font-medium text-gray-400">Formatted Invoice Data</label>
|
||||||
|
<button
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
className="btn btn-sm btn-primary gap-2"
|
||||||
|
disabled={!formattedText || formattedText.includes('No') || formattedText.includes('Error')}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Icon icon="mdi:check" className="h-4 w-4" />
|
||||||
|
Copied!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon icon="mdi:content-copy" className="h-4 w-4" />
|
||||||
|
Copy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="bg-base-300/50 p-3 rounded-lg mt-2 whitespace-pre-wrap break-words text-sm">
|
||||||
|
{formattedText}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
Format: N_1 {'{item_1}'} x{'{cost_1}'} each | N_2 {'{item_2}'} x{'{cost_2}'} each | Tax = {'{tax}'} | Tip = {'{tip}'} | Total = {'{total}'} from {'{location}'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Separate component for invoice table
|
// Separate component for invoice table
|
||||||
const InvoiceTable: React.FC<{ invoiceData: any, expectedAttendance?: number }> = ({ invoiceData, expectedAttendance }) => {
|
const InvoiceTable: React.FC<{ invoiceData: any, expectedAttendance?: number }> = ({ invoiceData, expectedAttendance }) => {
|
||||||
// If no invoice data is provided, show a message
|
// If no invoice data is provided, show a message
|
||||||
|
@ -913,6 +1079,8 @@ const InvoiceTable: React.FC<{ invoiceData: any, expectedAttendance?: number }>
|
||||||
const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }) => {
|
const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }) => {
|
||||||
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState<boolean>(false);
|
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState<boolean>(false);
|
||||||
const [selectedFile, setSelectedFile] = useState<{ name: string, displayName: string }>({ name: '', displayName: '' });
|
const [selectedFile, setSelectedFile] = useState<{ name: string, displayName: string }>({ name: '', displayName: '' });
|
||||||
|
const [flyersCompleted, setFlyersCompleted] = useState<boolean>(request.flyers_completed || false);
|
||||||
|
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||||
|
|
||||||
// Format date for display
|
// Format date for display
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
|
@ -931,8 +1099,33 @@ const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle flyers completed checkbox change
|
||||||
|
const handleFlyersCompletedChange = async (completed: boolean) => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
const { Update } = await import('../../../scripts/pocketbase/Update');
|
||||||
|
const update = Update.getInstance();
|
||||||
|
|
||||||
|
await update.updateField("event_request", request.id, "flyers_completed", completed);
|
||||||
|
|
||||||
|
setFlyersCompleted(completed);
|
||||||
|
toast.success(`Flyers completion status updated to ${completed ? 'completed' : 'not completed'}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update flyers completed status:', error);
|
||||||
|
toast.error('Failed to update flyers completion status');
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync local state with request prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
setFlyersCompleted(request.flyers_completed || false);
|
||||||
|
}, [request.flyers_completed]);
|
||||||
|
|
||||||
// Use the same utility functions as in the ASFundingTab
|
// Use the same utility functions as in the ASFundingTab
|
||||||
const getFileExtension = (filename: string): string => {
|
const getFileExtension = (filename: string): string => {
|
||||||
|
if (!filename || typeof filename !== 'string') return '';
|
||||||
const parts = filename.split('.');
|
const parts = filename.split('.');
|
||||||
return parts.length > 1 ? parts.pop()?.toLowerCase() || '' : '';
|
return parts.length > 1 ? parts.pop()?.toLowerCase() || '' : '';
|
||||||
};
|
};
|
||||||
|
@ -947,6 +1140,7 @@ const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFriendlyFileName = (filename: string, maxLength: number = 20): string => {
|
const getFriendlyFileName = (filename: string, maxLength: number = 20): string => {
|
||||||
|
if (!filename || typeof filename !== 'string') return 'Unknown File';
|
||||||
const basename = filename.split('/').pop() || filename;
|
const basename = filename.split('/').pop() || filename;
|
||||||
if (basename.length <= maxLength) return basename;
|
if (basename.length <= maxLength) return basename;
|
||||||
const extension = getFileExtension(basename);
|
const extension = getFileExtension(basename);
|
||||||
|
@ -993,6 +1187,46 @@ const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Flyers Completed Checkbox - Only show if flyers are needed */}
|
||||||
|
{request.flyers_needed && (
|
||||||
|
<motion.div
|
||||||
|
className="bg-base-300/20 p-4 rounded-lg"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.05 }}
|
||||||
|
>
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 mb-3">Completion Status</h4>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={flyersCompleted}
|
||||||
|
onChange={(e) => handleFlyersCompletedChange(e.target.checked)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
/>
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
Flyers completed by PR team
|
||||||
|
</label>
|
||||||
|
{isUpdating && (
|
||||||
|
<div className="loading loading-spinner loading-sm"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
{flyersCompleted ? (
|
||||||
|
<span className="badge badge-success gap-1">
|
||||||
|
<Icon icon="mdi:check-circle" className="h-3 w-3" />
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-warning gap-1">
|
||||||
|
<Icon icon="mdi:clock" className="h-3 w-3" />
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{request.flyers_needed && (
|
{request.flyers_needed && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
|
@ -1270,6 +1504,9 @@ const EventRequestDetails = ({
|
||||||
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
||||||
const [newStatus, setNewStatus] = useState<"submitted" | "pending" | "completed" | "declined">("pending");
|
const [newStatus, setNewStatus] = useState<"submitted" | "pending" | "completed" | "declined">("pending");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
// Add state for decline reason modal
|
||||||
|
const [isDeclineModalOpen, setIsDeclineModalOpen] = useState(false);
|
||||||
|
const [declineReason, setDeclineReason] = useState<string>('');
|
||||||
const [alertInfo, setAlertInfo] = useState<{ show: boolean; type: "success" | "error" | "warning" | "info"; message: string }>({
|
const [alertInfo, setAlertInfo] = useState<{ show: boolean; type: "success" | "error" | "warning" | "info"; message: string }>({
|
||||||
show: false,
|
show: false,
|
||||||
type: "info",
|
type: "info",
|
||||||
|
@ -1300,8 +1537,14 @@ const EventRequestDetails = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusChange = async (newStatus: "submitted" | "pending" | "completed" | "declined") => {
|
const handleStatusChange = async (newStatus: "submitted" | "pending" | "completed" | "declined") => {
|
||||||
setNewStatus(newStatus);
|
if (newStatus === 'declined') {
|
||||||
setIsConfirmModalOpen(true);
|
// Open decline reason modal instead of immediate confirmation
|
||||||
|
setDeclineReason('');
|
||||||
|
setIsDeclineModalOpen(true);
|
||||||
|
} else {
|
||||||
|
setNewStatus(newStatus);
|
||||||
|
setIsConfirmModalOpen(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmStatusChange = async () => {
|
const confirmStatusChange = async () => {
|
||||||
|
@ -1327,6 +1570,72 @@ const EventRequestDetails = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle decline with reason
|
||||||
|
const handleDeclineWithReason = async () => {
|
||||||
|
if (!declineReason.trim()) {
|
||||||
|
toast.error('Please provide a reason for declining');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
// Use Update service to update both status and decline reason
|
||||||
|
const { Update } = await import('../../../scripts/pocketbase/Update');
|
||||||
|
const update = Update.getInstance();
|
||||||
|
|
||||||
|
await update.updateFields("event_request", request.id, {
|
||||||
|
status: 'declined',
|
||||||
|
declined_reason: declineReason
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email notifications
|
||||||
|
const { EmailClient } = await import('../../../scripts/email/EmailClient');
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
const changedByUserId = auth.getUserId();
|
||||||
|
|
||||||
|
await EmailClient.notifyEventRequestStatusChange(
|
||||||
|
request.id,
|
||||||
|
request.status,
|
||||||
|
'declined',
|
||||||
|
changedByUserId || undefined,
|
||||||
|
declineReason
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send design team notification if PR materials were needed
|
||||||
|
if (request.flyers_needed) {
|
||||||
|
await EmailClient.notifyDesignTeam(request.id, 'declined');
|
||||||
|
}
|
||||||
|
|
||||||
|
setAlertInfo({
|
||||||
|
show: true,
|
||||||
|
type: "success",
|
||||||
|
message: "Event request has been declined successfully."
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsDeclineModalOpen(false);
|
||||||
|
setDeclineReason('');
|
||||||
|
|
||||||
|
// Call the parent's onStatusChange if needed for UI updates
|
||||||
|
await onStatusChange(request.id, 'declined');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error declining request:', error);
|
||||||
|
setAlertInfo({
|
||||||
|
show: true,
|
||||||
|
type: "error",
|
||||||
|
message: "Failed to decline event request. Please try again."
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cancel decline action
|
||||||
|
const cancelDecline = () => {
|
||||||
|
setIsDeclineModalOpen(false);
|
||||||
|
setDeclineReason('');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-transparent w-full">
|
<div className="bg-transparent w-full">
|
||||||
{/* Tabs navigation */}
|
{/* Tabs navigation */}
|
||||||
|
@ -1479,6 +1788,11 @@ const EventRequestDetails = ({
|
||||||
<label className="text-xs text-gray-400">Start Date & Time</label>
|
<label className="text-xs text-gray-400">Start Date & Time</label>
|
||||||
<p className="text-white">{formatDate(request.start_date_time)}</p>
|
<p className="text-white">{formatDate(request.start_date_time)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400">End Date & Time</label>
|
||||||
|
<p className="text-white">{formatDate(request.end_date_time)}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -1503,14 +1817,14 @@ const EventRequestDetails = ({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between bg-base-200/30 p-3 rounded-lg">
|
<div className="flex items-center justify-between bg-base-200/30 p-3 rounded-lg">
|
||||||
<p className="text-white">Room Reservation Needed</p>
|
<p className="text-white">Room Reservation Needed</p>
|
||||||
<div className={`badge ${request.room_reservation_needed ? 'badge-success' : 'badge-ghost'}`}>
|
<div className={`badge ${request.will_or_have_room_booking ? 'badge-success' : 'badge-ghost'}`}>
|
||||||
{request.room_reservation_needed ? 'Yes' : 'No'}
|
{request.will_or_have_room_booking ? 'Yes' : 'No'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{request.room_reservation_needed ? (
|
{request.will_or_have_room_booking ? (
|
||||||
<div className="bg-base-100/10 p-5 rounded-lg border border-base-100/10">
|
<div className="bg-base-100/10 p-5 rounded-lg border border-base-100/10">
|
||||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
||||||
<Icon icon="mdi:map-marker-outline" className="h-5 w-5 mr-2 text-primary" />
|
<Icon icon="mdi:map-marker-outline" className="h-5 w-5 mr-2 text-primary" />
|
||||||
|
@ -1519,14 +1833,40 @@ const EventRequestDetails = ({
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="bg-base-200/30 p-3 rounded-lg">
|
<div className="bg-base-200/30 p-3 rounded-lg">
|
||||||
<label className="text-xs text-gray-400 block mb-1">Room/Location</label>
|
<label className="text-xs text-gray-400 block mb-1">Room/Location</label>
|
||||||
<p className="text-white font-medium">{request.room_reservation_location || 'Not specified'}</p>
|
<p className="text-white font-medium">{request.location || 'Not specified'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-base-200/30 p-3 rounded-lg">
|
<div className="bg-base-200/30 p-3 rounded-lg">
|
||||||
<label className="text-xs text-gray-400 block mb-1">Confirmation Status</label>
|
<label className="text-xs text-gray-400 block mb-1">Confirmation Status</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`badge ${request.room_reservation_confirmed ? 'badge-success' : 'badge-warning'}`}>
|
<div className={`badge ${request.room_booking_files && request.room_booking_files.length > 0 ? 'badge-success' : 'badge-warning'}`}>
|
||||||
{request.room_reservation_confirmed ? 'Confirmed' : 'Pending'}
|
{request.room_booking_files && request.room_booking_files.length > 0 ? 'Booking Files Uploaded' : 'No Booking Files'}
|
||||||
</div>
|
</div>
|
||||||
|
{request.room_booking_files && request.room_booking_files.length > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{request.room_booking_files.map((fileId, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => {
|
||||||
|
// Dispatch event to update file preview modal
|
||||||
|
const event = new CustomEvent('filePreviewStateChange', {
|
||||||
|
detail: {
|
||||||
|
url: `https://pocketbase.ieeeucsd.org/api/files/event_request/${request.id}/${fileId}`,
|
||||||
|
filename: fileId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
// Open the modal
|
||||||
|
const modal = document.getElementById('file-preview-modal') as HTMLDialogElement;
|
||||||
|
if (modal) modal.showModal();
|
||||||
|
}}
|
||||||
|
className="btn btn-xs btn-primary"
|
||||||
|
>
|
||||||
|
View File {index + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1617,8 +1957,75 @@ const EventRequestDetails = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Decline Reason Modal */}
|
||||||
|
{isDeclineModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[300] flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
className="bg-base-300 rounded-lg p-6 w-full max-w-md"
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-bold mb-4">Decline Event Request</h3>
|
||||||
|
<p className="text-gray-300 mb-4">
|
||||||
|
Please provide a reason for declining "{request.name}". This will be sent to the submitter and they will need to resubmit with proper information.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-bordered w-full h-32 bg-base-100 text-white border-base-300 focus:border-primary"
|
||||||
|
placeholder="Enter decline reason (required)..."
|
||||||
|
value={declineReason}
|
||||||
|
onChange={(e) => setDeclineReason(e.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-400 mb-4">
|
||||||
|
{declineReason.length}/500 characters
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={cancelDecline}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-error"
|
||||||
|
onClick={handleDeclineWithReason}
|
||||||
|
disabled={!declineReason.trim() || isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-xs"></span>
|
||||||
|
Declining...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Decline Request'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File Preview Modal */}
|
||||||
|
<dialog id="file-preview-modal" className="modal modal-bottom sm:modal-middle">
|
||||||
|
<div className="modal-box bg-base-200 p-0 overflow-hidden max-w-4xl">
|
||||||
|
<div className="p-4">
|
||||||
|
<UniversalFilePreview isModal={true} />
|
||||||
|
</div>
|
||||||
|
<div className="modal-action mt-0 p-4 border-t border-base-300">
|
||||||
|
<form method="dialog">
|
||||||
|
<button className="btn btn-sm">Close</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" className="modal-backdrop">
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EventRequestDetails;
|
export default EventRequestDetails;
|
|
@ -1,11 +1,8 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Get } from '../../../scripts/pocketbase/Get';
|
|
||||||
import { Update } from '../../../scripts/pocketbase/Update';
|
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import EventRequestDetails from './EventRequestDetails';
|
|
||||||
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
|
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
|
||||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
|
|
||||||
|
@ -27,6 +24,8 @@ interface ExtendedEventRequest extends SchemaEventRequest {
|
||||||
invoice_data?: any;
|
invoice_data?: any;
|
||||||
invoice_files?: string[]; // Array of invoice file IDs
|
invoice_files?: string[]; // Array of invoice file IDs
|
||||||
status: "submitted" | "pending" | "completed" | "declined";
|
status: "submitted" | "pending" | "completed" | "declined";
|
||||||
|
declined_reason?: string; // Reason for declining the event request
|
||||||
|
flyers_completed?: boolean; // Track if flyers have been completed by PR team
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventRequestManagementTableProps {
|
interface EventRequestManagementTableProps {
|
||||||
|
@ -45,14 +44,18 @@ const EventRequestManagementTable = ({
|
||||||
const [eventRequests, setEventRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
|
const [eventRequests, setEventRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
|
||||||
const [filteredRequests, setFilteredRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
|
const [filteredRequests, setFilteredRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
|
||||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('active');
|
||||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||||
const [sortField, setSortField] = useState<string>('created');
|
const [sortField, setSortField] = useState<string>('start_date_time');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
const dataSync = DataSyncService.getInstance();
|
const dataSync = DataSyncService.getInstance();
|
||||||
// Add state for update modal
|
// Add state for update modal
|
||||||
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState<boolean>(false);
|
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState<boolean>(false);
|
||||||
const [requestToUpdate, setRequestToUpdate] = useState<ExtendedEventRequest | null>(null);
|
const [requestToUpdate, setRequestToUpdate] = useState<ExtendedEventRequest | null>(null);
|
||||||
|
// Add state for decline reason modal
|
||||||
|
const [isDeclineModalOpen, setIsDeclineModalOpen] = useState<boolean>(false);
|
||||||
|
const [declineReason, setDeclineReason] = useState<string>('');
|
||||||
|
const [requestToDecline, setRequestToDecline] = useState<ExtendedEventRequest | null>(null);
|
||||||
|
|
||||||
// Refresh event requests
|
// Refresh event requests
|
||||||
const refreshEventRequests = async () => {
|
const refreshEventRequests = async () => {
|
||||||
|
@ -65,13 +68,14 @@ const EventRequestManagementTable = ({
|
||||||
|
|
||||||
// console.log("Fetching event requests...");
|
// console.log("Fetching event requests...");
|
||||||
|
|
||||||
// Use DataSyncService to get data from IndexedDB with forced sync
|
// Use DataSyncService to get data from IndexedDB with forced sync and deletion detection
|
||||||
const updatedRequests = await dataSync.getData<ExtendedEventRequest>(
|
const updatedRequests = await dataSync.getData<ExtendedEventRequest>(
|
||||||
Collections.EVENT_REQUESTS,
|
Collections.EVENT_REQUESTS,
|
||||||
true, // Force sync
|
true, // Force sync
|
||||||
'', // No filter
|
'', // No filter - get all requests
|
||||||
'-created',
|
'-created',
|
||||||
'requested_user' // This is correct but we need to ensure it's working in the DataSyncService
|
'requested_user', // Expand user data
|
||||||
|
true // Enable deletion detection for all event requests
|
||||||
);
|
);
|
||||||
|
|
||||||
// If we still have "Unknown" users, try to fetch them directly
|
// If we still have "Unknown" users, try to fetch them directly
|
||||||
|
@ -133,9 +137,19 @@ const EventRequestManagementTable = ({
|
||||||
|
|
||||||
// Apply status filter
|
// Apply status filter
|
||||||
if (statusFilter !== 'all') {
|
if (statusFilter !== 'all') {
|
||||||
filtered = filtered.filter(request =>
|
if (statusFilter === 'active') {
|
||||||
request.status?.toLowerCase() === statusFilter.toLowerCase()
|
// Filter to show only submitted and pending events (hide completed and declined)
|
||||||
);
|
filtered = filtered.filter(request => {
|
||||||
|
const status = request.status?.toLowerCase();
|
||||||
|
return status === 'submitted' || status === 'pending' || !status; // Include requests without status (assume pending)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For specific status filters, treat empty status as 'pending'
|
||||||
|
filtered = filtered.filter(request => {
|
||||||
|
const status = request.status?.toLowerCase() || 'pending'; // Default empty status to 'pending'
|
||||||
|
return status === statusFilter.toLowerCase();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply search filter
|
// Apply search filter
|
||||||
|
@ -180,40 +194,125 @@ const EventRequestManagementTable = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update event request status
|
// Update event request status
|
||||||
const updateEventRequestStatus = async (id: string, status: "submitted" | "pending" | "completed" | "declined"): Promise<void> => {
|
const updateEventRequestStatus = async (id: string, status: "submitted" | "pending" | "completed" | "declined", declineReason?: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await onStatusChange(id, status);
|
// Find the event request to get its current status and name
|
||||||
|
const eventRequest = eventRequests.find(req => req.id === id);
|
||||||
|
const eventName = eventRequest?.name || 'Event';
|
||||||
|
const previousStatus = eventRequest?.status;
|
||||||
|
|
||||||
// Find the event request to get its name
|
// If declining, update with decline reason
|
||||||
|
if (status === 'declined' && declineReason) {
|
||||||
|
const { Update } = await import('../../../scripts/pocketbase/Update');
|
||||||
|
const update = Update.getInstance();
|
||||||
|
await update.updateFields("event_request", id, {
|
||||||
|
status: status,
|
||||||
|
declined_reason: declineReason
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await onStatusChange(id, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setEventRequests(prev =>
|
||||||
|
prev.map(request =>
|
||||||
|
request.id === id ? {
|
||||||
|
...request,
|
||||||
|
status,
|
||||||
|
...(status === 'declined' && declineReason ? { declined_reason: declineReason } : {})
|
||||||
|
} : request
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setFilteredRequests(prev =>
|
||||||
|
prev.map(request =>
|
||||||
|
request.id === id ? {
|
||||||
|
...request,
|
||||||
|
status,
|
||||||
|
...(status === 'declined' && declineReason ? { declined_reason: declineReason } : {})
|
||||||
|
} : request
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success(`"${eventName}" status updated to ${status}`);
|
||||||
|
|
||||||
|
// Send email notification for status change
|
||||||
|
try {
|
||||||
|
const { EmailClient } = await import('../../../scripts/email/EmailClient');
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
const changedByUserId = auth.getUserId();
|
||||||
|
|
||||||
|
if (previousStatus && previousStatus !== status) {
|
||||||
|
await EmailClient.notifyEventRequestStatusChange(
|
||||||
|
id,
|
||||||
|
previousStatus,
|
||||||
|
status,
|
||||||
|
changedByUserId || undefined,
|
||||||
|
status === 'declined' ? declineReason : undefined
|
||||||
|
);
|
||||||
|
console.log('Event request status change notification email sent successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send design team notifications for PR-related actions
|
||||||
|
if (eventRequest?.flyers_needed) {
|
||||||
|
if (status === 'declined') {
|
||||||
|
await EmailClient.notifyDesignTeam(id, 'declined');
|
||||||
|
console.log('Design team notified of declined PR request');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Failed to send event request status change notification email:', emailError);
|
||||||
|
// Don't show error to user - email failure shouldn't disrupt the main operation
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating status:', error);
|
||||||
|
toast.error('Failed to update status');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update PR status (flyers_completed)
|
||||||
|
const updatePRStatus = async (id: string, completed: boolean): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { Update } = await import('../../../scripts/pocketbase/Update');
|
||||||
|
const update = Update.getInstance();
|
||||||
|
|
||||||
|
await update.updateField("event_request", id, "flyers_completed", completed);
|
||||||
|
|
||||||
|
// Find the event request to get its details
|
||||||
const eventRequest = eventRequests.find(req => req.id === id);
|
const eventRequest = eventRequests.find(req => req.id === id);
|
||||||
const eventName = eventRequest?.name || 'Event';
|
const eventName = eventRequest?.name || 'Event';
|
||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
setEventRequests(prev =>
|
setEventRequests(prev =>
|
||||||
prev.map(request =>
|
prev.map(request =>
|
||||||
request.id === id ? { ...request, status } : request
|
request.id === id ? { ...request, flyers_completed: completed } : request
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
setFilteredRequests(prev =>
|
setFilteredRequests(prev =>
|
||||||
prev.map(request =>
|
prev.map(request =>
|
||||||
request.id === id ? { ...request, status } : request
|
request.id === id ? { ...request, flyers_completed: completed } : request
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Force sync to update IndexedDB
|
toast.success(`"${eventName}" PR status updated to ${completed ? 'completed' : 'pending'}`);
|
||||||
await dataSync.syncCollection<ExtendedEventRequest>(Collections.EVENT_REQUESTS);
|
|
||||||
|
// Send email notification if PR is completed
|
||||||
|
if (completed) {
|
||||||
|
try {
|
||||||
|
const { EmailClient } = await import('../../../scripts/email/EmailClient');
|
||||||
|
await EmailClient.notifyPRCompleted(id);
|
||||||
|
console.log('PR completion notification email sent successfully');
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Failed to send PR completion notification email:', emailError);
|
||||||
|
// Don't show error to user - email failure shouldn't disrupt the main operation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show success toast with event name
|
|
||||||
toast.success(`"${eventName}" status updated to ${status}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Find the event request to get its name
|
console.error('Error updating PR status:', error);
|
||||||
const eventRequest = eventRequests.find(req => req.id === id);
|
toast.error('Failed to update PR status');
|
||||||
const eventName = eventRequest?.name || 'Event';
|
|
||||||
|
|
||||||
// console.error('Error updating status:', error);
|
|
||||||
toast.error(`Failed to update status for "${eventName}"`);
|
|
||||||
throw error; // Re-throw the error to be caught by the caller
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -234,6 +333,50 @@ const EventRequestManagementTable = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Format date and time range for display
|
||||||
|
const formatDateTimeRange = (startDateString: string, endDateString: string) => {
|
||||||
|
if (!startDateString) return 'Not specified';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startDate = new Date(startDateString);
|
||||||
|
const endDate = endDateString ? new Date(endDateString) : null;
|
||||||
|
|
||||||
|
const startFormatted = startDate.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (endDate && endDate.getTime() !== startDate.getTime()) {
|
||||||
|
// Check if it's the same day
|
||||||
|
const isSameDay = startDate.toDateString() === endDate.toDateString();
|
||||||
|
|
||||||
|
if (isSameDay) {
|
||||||
|
// Same day, just show end time
|
||||||
|
const endTime = endDate.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
return `${startFormatted} - ${endTime}`;
|
||||||
|
} else {
|
||||||
|
// Different day, show full end date
|
||||||
|
const endFormatted = endDate.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
return `${startFormatted} - ${endFormatted}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return startFormatted;
|
||||||
|
} catch (e) {
|
||||||
|
return startDateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Get status badge class based on status
|
// Get status badge class based on status
|
||||||
const getStatusBadge = (status?: "submitted" | "pending" | "completed" | "declined") => {
|
const getStatusBadge = (status?: "submitted" | "pending" | "completed" | "declined") => {
|
||||||
if (!status) return 'badge-warning';
|
if (!status) return 'badge-warning';
|
||||||
|
@ -305,10 +448,42 @@ const EventRequestManagementTable = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle decline action with reason prompt
|
||||||
|
const handleDeclineAction = (request: ExtendedEventRequest) => {
|
||||||
|
setRequestToDecline(request);
|
||||||
|
setDeclineReason('');
|
||||||
|
setIsDeclineModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Confirm decline with reason
|
||||||
|
const confirmDecline = async () => {
|
||||||
|
if (!requestToDecline || !declineReason.trim()) {
|
||||||
|
toast.error('Please provide a reason for declining');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateEventRequestStatus(requestToDecline.id, 'declined', declineReason);
|
||||||
|
setIsDeclineModalOpen(false);
|
||||||
|
setRequestToDecline(null);
|
||||||
|
setDeclineReason('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error declining request:', error);
|
||||||
|
toast.error('Failed to decline request');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cancel decline action
|
||||||
|
const cancelDecline = () => {
|
||||||
|
setIsDeclineModalOpen(false);
|
||||||
|
setRequestToDecline(null);
|
||||||
|
setDeclineReason('');
|
||||||
|
};
|
||||||
|
|
||||||
// Apply filters when filter state changes
|
// Apply filters when filter state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyFilters();
|
applyFilters();
|
||||||
}, [statusFilter, searchTerm, sortField, sortDirection]);
|
}, [statusFilter, searchTerm, sortField, sortDirection, eventRequests]);
|
||||||
|
|
||||||
// Check authentication and refresh token if needed
|
// Check authentication and refresh token if needed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -450,6 +625,7 @@ const EventRequestManagementTable = ({
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
>
|
>
|
||||||
|
<option value="active">Active (Submitted & Pending)</option>
|
||||||
<option value="all">All Statuses</option>
|
<option value="all">All Statuses</option>
|
||||||
<option value="pending">Pending</option>
|
<option value="pending">Pending</option>
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
|
@ -492,7 +668,7 @@ const EventRequestManagementTable = ({
|
||||||
height: "auto"
|
height: "auto"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<table className="table table-zebra w-full">
|
<table className="table table-zebra w-full min-w-[600px]">
|
||||||
<thead className="bg-base-300/50 sticky top-0 z-10">
|
<thead className="bg-base-300/50 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
|
@ -513,7 +689,7 @@ const EventRequestManagementTable = ({
|
||||||
onClick={() => handleSortChange('start_date_time')}
|
onClick={() => handleSortChange('start_date_time')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
Date
|
Date & Time
|
||||||
{sortField === 'start_date_time' && (
|
{sortField === 'start_date_time' && (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||||
|
@ -535,6 +711,19 @@ const EventRequestManagementTable = ({
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="hidden lg:table-cell">PR Materials</th>
|
<th className="hidden lg:table-cell">PR Materials</th>
|
||||||
|
<th
|
||||||
|
className="cursor-pointer hover:bg-base-300 transition-colors hidden lg:table-cell"
|
||||||
|
onClick={() => handleSortChange('flyers_completed')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
PR Status
|
||||||
|
{sortField === 'flyers_completed' && (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
<th className="hidden lg:table-cell">AS Funding</th>
|
<th className="hidden lg:table-cell">AS Funding</th>
|
||||||
<th
|
<th
|
||||||
className="cursor-pointer hover:bg-base-300 transition-colors hidden md:table-cell"
|
className="cursor-pointer hover:bg-base-300 transition-colors hidden md:table-cell"
|
||||||
|
@ -562,7 +751,7 @@ const EventRequestManagementTable = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th>Actions</th>
|
<th className="w-20 min-w-[5rem]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -573,7 +762,11 @@ const EventRequestManagementTable = ({
|
||||||
{truncateText(request.name, 30)}
|
{truncateText(request.name, 30)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden md:table-cell">{formatDate(request.start_date_time)}</td>
|
<td className="hidden md:table-cell">
|
||||||
|
<div className="text-sm">
|
||||||
|
{formatDateTimeRange(request.start_date_time, request.end_date_time)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{(() => {
|
{(() => {
|
||||||
const { name, email } = getUserDisplayInfo(request);
|
const { name, email } = getUserDisplayInfo(request);
|
||||||
|
@ -592,6 +785,28 @@ const EventRequestManagementTable = ({
|
||||||
<span className="badge badge-ghost badge-sm">No</span>
|
<span className="badge badge-ghost badge-sm">No</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="hidden lg:table-cell">
|
||||||
|
{request.flyers_needed ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={request.flyers_completed || false}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updatePRStatus(request.id, e.target.checked);
|
||||||
|
}}
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
title="Mark PR materials as completed"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={false}
|
||||||
|
disabled={true}
|
||||||
|
className="checkbox checkbox-disabled opacity-30"
|
||||||
|
title="PR materials not needed for this event"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="hidden lg:table-cell">
|
<td className="hidden lg:table-cell">
|
||||||
{request.as_funding_required ? (
|
{request.as_funding_required ? (
|
||||||
<span className="badge badge-success badge-sm">Yes</span>
|
<span className="badge badge-success badge-sm">Yes</span>
|
||||||
|
@ -606,16 +821,17 @@ const EventRequestManagementTable = ({
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-center">
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-primary btn-outline btn-sm gap-2"
|
className="btn btn-sm btn-primary btn-outline gap-1 min-h-[2rem] max-w-[5rem] flex-shrink-0"
|
||||||
onClick={() => openDetailModal(request)}
|
onClick={() => openDetailModal(request)}
|
||||||
|
title="View Event Details"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
View
|
<span className="hidden sm:inline">View</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
@ -625,6 +841,50 @@ const EventRequestManagementTable = ({
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Decline Reason Modal */}
|
||||||
|
{isDeclineModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
className="bg-base-200 rounded-lg p-6 w-full max-w-md shadow-xl"
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">
|
||||||
|
Decline Event Request
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-300 mb-4">
|
||||||
|
Please provide a reason for declining "{requestToDecline?.name}". This will be sent to the submitter.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-bordered w-full h-32 bg-base-300 text-white border-base-300 focus:border-primary"
|
||||||
|
placeholder="Enter decline reason (required)..."
|
||||||
|
value={declineReason}
|
||||||
|
onChange={(e) => setDeclineReason(e.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-400 mb-4">
|
||||||
|
{declineReason.length}/500 characters
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={cancelDecline}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-error"
|
||||||
|
onClick={confirmDecline}
|
||||||
|
disabled={!declineReason.trim()}
|
||||||
|
>
|
||||||
|
Decline Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,11 +3,11 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import EventRequestDetails from './EventRequestDetails';
|
import EventRequestDetails from './EventRequestDetails';
|
||||||
import EventRequestManagementTable from './EventRequestManagementTable';
|
import EventRequestManagementTable from './EventRequestManagementTable';
|
||||||
import { Update } from '../../../scripts/pocketbase/Update';
|
import { Update } from '../../../scripts/pocketbase/Update';
|
||||||
import { Collections, EventRequestStatus } from '../../../schemas/pocketbase/schema';
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
|
||||||
import { Get } from '../../../scripts/pocketbase/Get';
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
|
import { EmailClient } from '../../../scripts/email/EmailClient';
|
||||||
import type { EventRequest } from '../../../schemas/pocketbase/schema';
|
import type { EventRequest } from '../../../schemas/pocketbase/schema';
|
||||||
|
|
||||||
// Extended EventRequest interface to include expanded fields that might come from the API
|
// Extended EventRequest interface to include expanded fields that might come from the API
|
||||||
|
@ -269,9 +269,14 @@ const EventRequestModal: React.FC<EventRequestModalProps> = ({ eventRequests })
|
||||||
const update = Update.getInstance();
|
const update = Update.getInstance();
|
||||||
await update.updateField("event_request", id, "status", status);
|
await update.updateField("event_request", id, "status", status);
|
||||||
|
|
||||||
// Force sync to update IndexedDB
|
// Force sync to update IndexedDB with deletion detection enabled
|
||||||
const dataSync = DataSyncService.getInstance();
|
const dataSync = DataSyncService.getInstance();
|
||||||
await dataSync.syncCollection(Collections.EVENT_REQUESTS);
|
await dataSync.syncCollection(Collections.EVENT_REQUESTS, "", "-created", {}, true);
|
||||||
|
|
||||||
|
// Find the request to get its name and previous status
|
||||||
|
const request = localEventRequests.find((req) => req.id === id);
|
||||||
|
const eventName = request?.name || "Event";
|
||||||
|
const previousStatus = request?.status;
|
||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
setLocalEventRequests(prevRequests =>
|
setLocalEventRequests(prevRequests =>
|
||||||
|
@ -280,13 +285,18 @@ const EventRequestModal: React.FC<EventRequestModalProps> = ({ eventRequests })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find the request to get its name
|
|
||||||
const request = localEventRequests.find((req) => req.id === id);
|
|
||||||
const eventName = request?.name || "Event";
|
|
||||||
|
|
||||||
// Notify success
|
// Notify success
|
||||||
toast.success(`"${eventName}" status updated to ${status}`);
|
toast.success(`"${eventName}" status updated to ${status}`);
|
||||||
|
|
||||||
|
// Send email notification for status change (non-blocking)
|
||||||
|
try {
|
||||||
|
await EmailClient.notifyEventRequestStatusChange(id, previousStatus || 'unknown', status);
|
||||||
|
console.log('Event request status change notification email sent successfully');
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Failed to send event request status change notification email:', emailError);
|
||||||
|
// Don't show error to user - email failure shouldn't disrupt the main operation
|
||||||
|
}
|
||||||
|
|
||||||
// Dispatch event for other components
|
// Dispatch event for other components
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new CustomEvent("status-updated", {
|
new CustomEvent("status-updated", {
|
||||||
|
|
|
@ -1,15 +1,13 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||||
import { DataSyncService } from "../../../scripts/database/DataSyncService";
|
|
||||||
import { Collections } from "../../../schemas/pocketbase/schema";
|
import { Collections } from "../../../schemas/pocketbase/schema";
|
||||||
import type { Event, Log, User } from "../../../schemas/pocketbase";
|
import type { Event, User, LimitedUser } from "../../../schemas/pocketbase";
|
||||||
import { Get } from "../../../scripts/pocketbase/Get";
|
import { Get } from "../../../scripts/pocketbase/Get";
|
||||||
import type { EventAttendee } from "../../../schemas/pocketbase";
|
import type { EventAttendee } from "../../../schemas/pocketbase";
|
||||||
import { Update } from "../../../scripts/pocketbase/Update";
|
import { Update } from "../../../scripts/pocketbase/Update";
|
||||||
|
|
||||||
// Extended User interface with points property
|
// Extended User interface with member_type property
|
||||||
interface ExtendedUser extends User {
|
interface ExtendedUser extends User {
|
||||||
points?: number;
|
|
||||||
member_type?: string;
|
member_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,47 +82,44 @@ export function Stats() {
|
||||||
|
|
||||||
setEventsAttended(attendedEvents.totalItems);
|
setEventsAttended(attendedEvents.totalItems);
|
||||||
|
|
||||||
// Get user points - either from the user record or calculate from attendees
|
// Calculate points from attendees
|
||||||
let totalPoints = 0;
|
let totalPoints = 0;
|
||||||
|
|
||||||
// Calculate quarterly points
|
// Calculate quarterly points
|
||||||
const quarterStartDate = getCurrentQuarterStartDate();
|
const quarterStartDate = getCurrentQuarterStartDate();
|
||||||
let pointsThisQuarter = 0;
|
let pointsThisQuarter = 0;
|
||||||
|
|
||||||
// If user has points field, use that for total points
|
// Calculate both total and quarterly points from attendees
|
||||||
if (currentUser && currentUser.points !== undefined) {
|
attendedEvents.items.forEach(attendee => {
|
||||||
totalPoints = currentUser.points;
|
const points = attendee.points_earned || 0;
|
||||||
|
totalPoints += points;
|
||||||
|
|
||||||
// Still need to calculate quarterly points from attendees
|
const checkinDate = new Date(attendee.time_checked_in);
|
||||||
attendedEvents.items.forEach(attendee => {
|
if (checkinDate >= quarterStartDate) {
|
||||||
const checkinDate = new Date(attendee.time_checked_in);
|
pointsThisQuarter += points;
|
||||||
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;
|
|
||||||
|
|
||||||
const checkinDate = new Date(attendee.time_checked_in);
|
// Try to get the LimitedUser record to check if points match
|
||||||
if (checkinDate >= quarterStartDate) {
|
try {
|
||||||
pointsThisQuarter += points;
|
const limitedUserRecord = await get.getOne(
|
||||||
}
|
Collections.LIMITED_USERS,
|
||||||
});
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
// Update the user record with calculated points if needed
|
if (limitedUserRecord && limitedUserRecord.points) {
|
||||||
if (currentUser) {
|
|
||||||
try {
|
try {
|
||||||
const update = Update.getInstance();
|
// Parse the points JSON string
|
||||||
await update.updateFields(Collections.USERS, currentUser.id, {
|
const parsedPoints = JSON.parse(limitedUserRecord.points);
|
||||||
points: totalPoints
|
if (parsedPoints !== totalPoints) {
|
||||||
});
|
console.log(`Points mismatch: LimitedUser has ${parsedPoints}, calculated ${totalPoints}`);
|
||||||
} catch (error) {
|
}
|
||||||
console.error("Error updating user points:", error);
|
} catch (e) {
|
||||||
|
console.error('Error parsing points from LimitedUser:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// LimitedUser record might not exist yet, that's okay
|
||||||
}
|
}
|
||||||
|
|
||||||
setPointsEarned(totalPoints);
|
setPointsEarned(totalPoints);
|
||||||
|
@ -199,7 +194,7 @@ export function Stats() {
|
||||||
</div>
|
</div>
|
||||||
<div className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-secondary transition-all duration-300 hover:-translate-y-1 transform">
|
<div className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-secondary transition-all duration-300 hover:-translate-y-1 transform">
|
||||||
<div className="stat">
|
<div className="stat">
|
||||||
<div className="stat-title font-medium opacity-80">Loyalty Points</div>
|
<div className="stat-title font-medium opacity-80">Points</div>
|
||||||
<div className="stat-value text-secondary">{loyaltyPoints}</div>
|
<div className="stat-value text-secondary">{loyaltyPoints}</div>
|
||||||
<div className="stat-desc flex flex-col items-start gap-1 mt-1">
|
<div className="stat-desc flex flex-col items-start gap-1 mt-1">
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
|
|
187
src/components/dashboard/ResumeDatabase.astro
Normal file
187
src/components/dashboard/ResumeDatabase.astro
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
---
|
||||||
|
import ResumeList from "./ResumeDatabase/ResumeList";
|
||||||
|
import ResumeFilters from "./ResumeDatabase/ResumeFilters";
|
||||||
|
import ResumeSearch from "./ResumeDatabase/ResumeSearch";
|
||||||
|
import ResumeDetail from "./ResumeDatabase/ResumeDetail";
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div
|
||||||
|
class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold">Resume Database</h2>
|
||||||
|
<p class="text-base-content/70">
|
||||||
|
Search and filter student resumes for recruitment opportunities
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="refreshResumesBtn" class="btn btn-sm btn-outline">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-refresh-cw"
|
||||||
|
>
|
||||||
|
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
|
||||||
|
></path>
|
||||||
|
<path d="M21 3v5h-5"></path>
|
||||||
|
<path
|
||||||
|
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
|
||||||
|
></path>
|
||||||
|
<path d="M3 21v-5h5"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resume Database Interface -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
<!-- Filters Panel -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<div class="card bg-base-100 shadow-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Filters</h3>
|
||||||
|
<ResumeFilters client:load />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resume List and Detail View -->
|
||||||
|
<div class="lg:col-span-3 space-y-6">
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="card bg-base-100 shadow-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<ResumeSearch client:load />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resume List -->
|
||||||
|
<div class="card bg-base-100 shadow-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Student Resumes</h3>
|
||||||
|
<ResumeList client:load />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resume Detail View (initially hidden, shown when a resume is selected) -->
|
||||||
|
<div
|
||||||
|
id="resumeDetailContainer"
|
||||||
|
class="card bg-base-100 shadow-md hidden"
|
||||||
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="card-title text-lg">Resume Details</h3>
|
||||||
|
<button
|
||||||
|
id="closeResumeDetail"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-x"
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18"></path>
|
||||||
|
<path d="m6 6 12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ResumeDetail client:load />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Authentication } from "../../scripts/pocketbase/Authentication";
|
||||||
|
import { Get } from "../../scripts/pocketbase/Get";
|
||||||
|
import { Realtime } from "../../scripts/pocketbase/Realtime";
|
||||||
|
import { Collections } from "../../schemas/pocketbase/schema";
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
const get = Get.getInstance();
|
||||||
|
const realtime = Realtime.getInstance();
|
||||||
|
|
||||||
|
// Initialize the resume database
|
||||||
|
async function initResumeDatabase() {
|
||||||
|
if (!auth.isAuthenticated()) {
|
||||||
|
console.error("User not authenticated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set up event listeners
|
||||||
|
document
|
||||||
|
.getElementById("refreshResumesBtn")
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
// Dispatch custom event to notify components to refresh
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("resumeDatabaseRefresh")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close resume detail view
|
||||||
|
document
|
||||||
|
.getElementById("closeResumeDetail")
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
document
|
||||||
|
.getElementById("resumeDetailContainer")
|
||||||
|
?.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up realtime updates
|
||||||
|
setupRealtimeUpdates();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error initializing resume database:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up realtime updates
|
||||||
|
function setupRealtimeUpdates() {
|
||||||
|
// Subscribe to users collection for resume updates
|
||||||
|
realtime.subscribeToCollection(Collections.USERS, (data) => {
|
||||||
|
console.log("User data updated:", data);
|
||||||
|
// Dispatch custom event to notify components to refresh
|
||||||
|
window.dispatchEvent(new CustomEvent("resumeDatabaseRefresh"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when document is ready
|
||||||
|
document.addEventListener("DOMContentLoaded", initResumeDatabase);
|
||||||
|
|
||||||
|
// Custom event listener for resume selection
|
||||||
|
window.addEventListener("resumeSelected", (e) => {
|
||||||
|
const customEvent = e as CustomEvent;
|
||||||
|
const resumeId = customEvent.detail.resumeId;
|
||||||
|
|
||||||
|
if (resumeId) {
|
||||||
|
// Show the resume detail container
|
||||||
|
document
|
||||||
|
.getElementById("resumeDetailContainer")
|
||||||
|
?.classList.remove("hidden");
|
||||||
|
|
||||||
|
// Dispatch event to the ResumeDetail component
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("loadResumeDetail", {
|
||||||
|
detail: { resumeId },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
194
src/components/dashboard/ResumeDatabase/ResumeDetail.tsx
Normal file
194
src/components/dashboard/ResumeDatabase/ResumeDetail.tsx
Normal file
|
@ -0,0 +1,194 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
|
import type { User } from '../../../schemas/pocketbase/schema';
|
||||||
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
|
|
||||||
|
interface ResumeUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
major?: string;
|
||||||
|
graduation_year?: number;
|
||||||
|
resume?: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResumeUrl(user: ResumeUser): string | undefined {
|
||||||
|
if (!user.resume) return undefined;
|
||||||
|
return `https://pocketbase.ieeeucsd.org/api/files/users/${user.id}/${user.resume}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResumeDetail() {
|
||||||
|
const [user, setUser] = useState<ResumeUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const get = Get.getInstance();
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen for resume selection
|
||||||
|
const handleResumeSelection = (event: CustomEvent) => {
|
||||||
|
const { resumeId } = event.detail;
|
||||||
|
if (resumeId) {
|
||||||
|
loadResumeDetails(resumeId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('loadResumeDetail', handleResumeSelection as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('loadResumeDetail', handleResumeSelection as EventListener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadResumeDetails = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Get user details
|
||||||
|
const user = await get.getOne<User>(Collections.USERS, userId);
|
||||||
|
|
||||||
|
if (!user || !user.resume) {
|
||||||
|
setError('Resume not found');
|
||||||
|
setUser(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map to our simplified format
|
||||||
|
setUser({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
major: user.major,
|
||||||
|
graduation_year: user.graduation_year,
|
||||||
|
resume: user.resume,
|
||||||
|
avatar: user.avatar
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading resume details:', err);
|
||||||
|
setError('Failed to load resume details');
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center py-10">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="alert alert-error">
|
||||||
|
<div className="flex-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mx-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||||
|
</svg>
|
||||||
|
<label>{error}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-10">
|
||||||
|
<p className="text-base-content/70">Select a resume to view details</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Student Information */}
|
||||||
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="avatar">
|
||||||
|
<div className="w-24 h-24 rounded-xl">
|
||||||
|
{user.avatar ? (
|
||||||
|
<img src={user.avatar} alt={user.name} />
|
||||||
|
) : (
|
||||||
|
<div className="bg-primary text-primary-content flex items-center justify-center w-full h-full">
|
||||||
|
<span className="text-2xl font-bold">{user.name.charAt(0)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-grow">
|
||||||
|
<h3 className="text-xl font-bold">{user.name}</h3>
|
||||||
|
<p className="text-base-content/70">{user.email}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-base-content/50">Major</h4>
|
||||||
|
<p>{user.major || 'Not specified'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-base-content/50">Graduation Year</h4>
|
||||||
|
<p>{user.graduation_year || 'Not specified'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resume Preview */}
|
||||||
|
<div className="border border-base-300 rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-base-200 px-4 py-2 border-b border-base-300 flex justify-between items-center">
|
||||||
|
<h3 className="font-medium">Resume</h3>
|
||||||
|
<a
|
||||||
|
href={getResumeUrl(user)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-base-100">
|
||||||
|
{user.resume && user.resume.toLowerCase().endsWith('.pdf') ? (
|
||||||
|
<div className="aspect-[8.5/11] w-full">
|
||||||
|
<iframe
|
||||||
|
src={`${getResumeUrl(user)}#toolbar=0&navpanes=0`}
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
title={`${user.name}'s Resume`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : user.resume && user.resume.toLowerCase().endsWith('.docx') ? (
|
||||||
|
<div className="aspect-[8.5/11] w-full">
|
||||||
|
<iframe
|
||||||
|
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(getResumeUrl(user) ?? '')}`}
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
title={`${user.name}'s Resume`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-10">
|
||||||
|
<p className="text-base-content/70">
|
||||||
|
Resume preview not available. Click the download button to view the resume.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<a
|
||||||
|
href={`mailto:${user.email}?subject=Regarding%20Your%20Resume&body=Hello%20${user.name},%0A%0AI%20found%20your%20resume%20in%20the%20IEEE%20UCSD%20database%20and%20would%20like%20to%20discuss%20potential%20opportunities.%0A%0ABest%20regards,`}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
Contact Student
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
172
src/components/dashboard/ResumeDatabase/ResumeFilters.tsx
Normal file
172
src/components/dashboard/ResumeDatabase/ResumeFilters.tsx
Normal file
|
@ -0,0 +1,172 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
|
import type { User } from '../../../schemas/pocketbase/schema';
|
||||||
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
|
|
||||||
|
export default function ResumeFilters() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [majors, setMajors] = useState<string[]>([]);
|
||||||
|
const [graduationYears, setGraduationYears] = useState<number[]>([]);
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [selectedMajor, setSelectedMajor] = useState<string>('all');
|
||||||
|
const [selectedGraduationYear, setSelectedGraduationYear] = useState<string>('all');
|
||||||
|
|
||||||
|
const get = Get.getInstance();
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFilterOptions();
|
||||||
|
|
||||||
|
// Listen for refresh requests
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadFilterOptions();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resumeDatabaseRefresh', handleRefresh);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resumeDatabaseRefresh', handleRefresh);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// When filters change, dispatch event to notify parent
|
||||||
|
useEffect(() => {
|
||||||
|
dispatchFilterChange();
|
||||||
|
}, [selectedMajor, selectedGraduationYear]);
|
||||||
|
|
||||||
|
const loadFilterOptions = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Get all users with resumes
|
||||||
|
const filter = "resume != null && resume != ''";
|
||||||
|
const users = await get.getAll<User>(Collections.USERS, filter);
|
||||||
|
|
||||||
|
// Extract unique majors
|
||||||
|
const uniqueMajors = new Set<string>();
|
||||||
|
users.forEach(user => {
|
||||||
|
if (user.major) {
|
||||||
|
uniqueMajors.add(user.major);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract unique graduation years
|
||||||
|
const uniqueGradYears = new Set<number>();
|
||||||
|
users.forEach(user => {
|
||||||
|
if (user.graduation_year) {
|
||||||
|
uniqueGradYears.add(user.graduation_year);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort majors alphabetically
|
||||||
|
const sortedMajors = Array.from(uniqueMajors).sort();
|
||||||
|
|
||||||
|
// Sort graduation years in ascending order
|
||||||
|
const sortedGradYears = Array.from(uniqueGradYears).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
setMajors(sortedMajors);
|
||||||
|
setGraduationYears(sortedGradYears);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading filter options:', err);
|
||||||
|
setError('Failed to load filter options');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatchFilterChange = () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('resumeFilterChange', {
|
||||||
|
detail: {
|
||||||
|
major: selectedMajor,
|
||||||
|
graduationYear: selectedGraduationYear
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMajorChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setSelectedMajor(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGraduationYearChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setSelectedGraduationYear(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetFilters = () => {
|
||||||
|
setSelectedMajor('all');
|
||||||
|
setSelectedGraduationYear('all');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center py-4">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="alert alert-error">
|
||||||
|
<div className="flex-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mx-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||||
|
</svg>
|
||||||
|
<label>{error}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Major Filter */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Major</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered w-full"
|
||||||
|
value={selectedMajor}
|
||||||
|
onChange={handleMajorChange}
|
||||||
|
>
|
||||||
|
<option value="all">All Majors</option>
|
||||||
|
{majors.map(major => (
|
||||||
|
<option key={major} value={major}>{major}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Graduation Year Filter */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Graduation Year</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered w-full"
|
||||||
|
value={selectedGraduationYear}
|
||||||
|
onChange={handleGraduationYearChange}
|
||||||
|
>
|
||||||
|
<option value="all">All Years</option>
|
||||||
|
{graduationYears.map(year => (
|
||||||
|
<option key={year} value={year}>{year}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Filters Button */}
|
||||||
|
<div className="form-control mt-6">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
onClick={handleResetFilters}
|
||||||
|
>
|
||||||
|
Reset Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
254
src/components/dashboard/ResumeDatabase/ResumeList.tsx
Normal file
254
src/components/dashboard/ResumeDatabase/ResumeList.tsx
Normal file
|
@ -0,0 +1,254 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
|
import type { User } from '../../../schemas/pocketbase/schema';
|
||||||
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
|
|
||||||
|
interface ResumeUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
major?: string;
|
||||||
|
graduation_year?: number;
|
||||||
|
resume?: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResumeList() {
|
||||||
|
const [users, setUsers] = useState<ResumeUser[]>([]);
|
||||||
|
const [filteredUsers, setFilteredUsers] = useState<ResumeUser[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const usersPerPage = 10;
|
||||||
|
|
||||||
|
const get = Get.getInstance();
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadResumes();
|
||||||
|
|
||||||
|
// Listen for filter changes
|
||||||
|
const handleFilterChange = (event: CustomEvent) => {
|
||||||
|
applyFilters(event.detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for search changes
|
||||||
|
const handleSearchChange = (event: CustomEvent) => {
|
||||||
|
applySearch(event.detail.searchQuery);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for refresh requests
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadResumes();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resumeFilterChange', handleFilterChange as EventListener);
|
||||||
|
window.addEventListener('resumeSearchChange', handleSearchChange as EventListener);
|
||||||
|
window.addEventListener('resumeDatabaseRefresh', handleRefresh);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resumeFilterChange', handleFilterChange as EventListener);
|
||||||
|
window.removeEventListener('resumeSearchChange', handleSearchChange as EventListener);
|
||||||
|
window.removeEventListener('resumeDatabaseRefresh', handleRefresh);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadResumes = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Get all users with resumes
|
||||||
|
const filter = "resume != null && resume != ''";
|
||||||
|
const users = await get.getAll<User>(Collections.USERS, filter);
|
||||||
|
|
||||||
|
// Map to our simplified format
|
||||||
|
const resumeUsers = users
|
||||||
|
.filter(user => user.resume) // Ensure resume exists
|
||||||
|
.map(user => ({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
major: user.major,
|
||||||
|
graduation_year: user.graduation_year,
|
||||||
|
resume: user.resume,
|
||||||
|
avatar: user.avatar
|
||||||
|
}));
|
||||||
|
|
||||||
|
setUsers(resumeUsers);
|
||||||
|
setFilteredUsers(resumeUsers);
|
||||||
|
setCurrentPage(1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading resumes:', err);
|
||||||
|
setError('Failed to load resume data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFilters = (filters: any) => {
|
||||||
|
let filtered = [...users];
|
||||||
|
|
||||||
|
// Apply major filter
|
||||||
|
if (filters.major && filters.major !== 'all') {
|
||||||
|
filtered = filtered.filter(user => {
|
||||||
|
if (!user.major) return false;
|
||||||
|
return user.major.toLowerCase().includes(filters.major.toLowerCase());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply graduation year filter
|
||||||
|
if (filters.graduationYear && filters.graduationYear !== 'all') {
|
||||||
|
const year = parseInt(filters.graduationYear);
|
||||||
|
filtered = filtered.filter(user => user.graduation_year === year);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredUsers(filtered);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySearch = (searchQuery: string) => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setFilteredUsers(users);
|
||||||
|
setCurrentPage(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const filtered = users.filter(user =>
|
||||||
|
user.name.toLowerCase().includes(query) ||
|
||||||
|
(user.major && user.major.toLowerCase().includes(query))
|
||||||
|
);
|
||||||
|
|
||||||
|
setFilteredUsers(filtered);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResumeClick = (userId: string) => {
|
||||||
|
// Dispatch event to notify parent component
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('resumeSelected', {
|
||||||
|
detail: { resumeId: userId }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get current users for pagination
|
||||||
|
const indexOfLastUser = currentPage * usersPerPage;
|
||||||
|
const indexOfFirstUser = indexOfLastUser - usersPerPage;
|
||||||
|
const currentUsers = filteredUsers.slice(indexOfFirstUser, indexOfLastUser);
|
||||||
|
const totalPages = Math.ceil(filteredUsers.length / usersPerPage);
|
||||||
|
|
||||||
|
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center py-10">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="alert alert-error">
|
||||||
|
<div className="flex-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mx-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||||
|
</svg>
|
||||||
|
<label>{error}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredUsers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-10">
|
||||||
|
<p className="text-base-content/70">No resumes found matching your criteria</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Student</th>
|
||||||
|
<th>Major</th>
|
||||||
|
<th>Graduation Year</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{currentUsers.map(user => (
|
||||||
|
<tr key={user.id} className="hover">
|
||||||
|
<td>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="avatar">
|
||||||
|
<div className="mask mask-squircle w-12 h-12">
|
||||||
|
{user.avatar ? (
|
||||||
|
<img src={user.avatar} alt={user.name} />
|
||||||
|
) : (
|
||||||
|
<div className="bg-primary text-primary-content flex items-center justify-center w-full h-full">
|
||||||
|
<span className="text-lg font-bold">{user.name.charAt(0)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold">{user.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{user.major || 'Not specified'}</td>
|
||||||
|
<td>{user.graduation_year || 'Not specified'}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={() => handleResumeClick(user.id)}
|
||||||
|
>
|
||||||
|
View Resume
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-center mt-6">
|
||||||
|
<div className="btn-group">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => paginate(Math.max(1, currentPage - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
«
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => (
|
||||||
|
<button
|
||||||
|
key={i + 1}
|
||||||
|
className={`btn btn-sm ${currentPage === i + 1 ? 'btn-active' : ''}`}
|
||||||
|
onClick={() => paginate(i + 1)}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => paginate(Math.min(totalPages, currentPage + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
»
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
73
src/components/dashboard/ResumeDatabase/ResumeSearch.tsx
Normal file
73
src/components/dashboard/ResumeDatabase/ResumeSearch.tsx
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ResumeSearch() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||||
|
|
||||||
|
// Debounce search input to avoid too many updates
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedQuery(searchQuery);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// When debounced query changes, dispatch event to notify parent
|
||||||
|
useEffect(() => {
|
||||||
|
dispatchSearchChange();
|
||||||
|
}, [debouncedQuery]);
|
||||||
|
|
||||||
|
const dispatchSearchChange = () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('resumeSearchChange', {
|
||||||
|
detail: {
|
||||||
|
searchQuery: debouncedQuery
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="relative flex-grow">
|
||||||
|
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<svg className="w-5 h-5 text-gray-500 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or major..."
|
||||||
|
className="w-full pl-10 pr-10 py-2 border border-gray-300 dark:border-gray-700 rounded-lg
|
||||||
|
bg-white/90 dark:bg-gray-800/90 text-gray-700 dark:text-gray-200 focus:outline-none
|
||||||
|
focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -5,7 +5,6 @@ import AccountSecuritySettings from "./SettingsSection/AccountSecuritySettings";
|
||||||
import NotificationSettings from "./SettingsSection/NotificationSettings";
|
import NotificationSettings from "./SettingsSection/NotificationSettings";
|
||||||
import DisplaySettings from "./SettingsSection/DisplaySettings";
|
import DisplaySettings from "./SettingsSection/DisplaySettings";
|
||||||
import ResumeSettings from "./SettingsSection/ResumeSettings";
|
import ResumeSettings from "./SettingsSection/ResumeSettings";
|
||||||
import EmailRequestSettings from "./SettingsSection/EmailRequestSettings";
|
|
||||||
import ThemeToggle from "./universal/ThemeToggle";
|
import ThemeToggle from "./universal/ThemeToggle";
|
||||||
|
|
||||||
// Import environment variables
|
// Import environment variables
|
||||||
|
@ -131,27 +130,6 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- IEEE Email Request Card -->
|
|
||||||
<div
|
|
||||||
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
|
|
||||||
>
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="inline-flex items-center justify-center p-3 rounded-full bg-primary text-primary-foreground"
|
|
||||||
>
|
|
||||||
<Icon name="heroicons:envelope" class="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
IEEE Email Address
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm opacity-70 mb-4">
|
|
||||||
Request an official IEEE UCSD email address (officers only)
|
|
||||||
</p>
|
|
||||||
<div class="h-px w-full bg-border my-4"></div>
|
|
||||||
<EmailRequestSettings client:load />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Account Security Settings Card -->
|
<!-- Account Security Settings Card -->
|
||||||
<div
|
<div
|
||||||
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
|
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
|
||||||
|
@ -215,13 +193,11 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
|
||||||
|
|
||||||
<!-- Display Settings Card -->
|
<!-- Display Settings Card -->
|
||||||
<div
|
<div
|
||||||
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform"
|
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform"
|
||||||
>
|
>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h3 class="card-title flex items-center gap-3">
|
<h3 class="card-title flex items-center gap-3">
|
||||||
<div
|
<div class="badge badge-primary p-3">
|
||||||
class="inline-flex items-center justify-center p-3 rounded-full bg-primary text-primary-foreground"
|
|
||||||
>
|
|
||||||
<Icon name="heroicons:computer-desktop" class="h-5 w-5" />
|
<Icon name="heroicons:computer-desktop" class="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
Display Settings
|
Display Settings
|
||||||
|
@ -229,18 +205,14 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
|
||||||
<p class="text-sm opacity-70 mb-4">
|
<p class="text-sm opacity-70 mb-4">
|
||||||
Customize your dashboard appearance and display preferences
|
Customize your dashboard appearance and display preferences
|
||||||
</p>
|
</p>
|
||||||
<div class="h-px w-full bg-border my-4"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
<div
|
<div class="alert alert-warning mb-4">
|
||||||
class="flex p-4 mb-4 text-sm rounded-lg bg-warning/20 text-warning-foreground"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="flex-shrink-0 w-5 h-5 mr-3"
|
class="stroke-current shrink-0 h-6 w-6"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
|
||||||
><path
|
><path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|
|
@ -59,16 +59,7 @@ export default function AccountSecuritySettings({
|
||||||
checkAuth();
|
checkAuth();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
// No logout functions needed here as logout is handled in the dashboard menu
|
||||||
try {
|
|
||||||
await logger.send('logout', 'auth', 'User manually logged out from settings page');
|
|
||||||
await auth.logout();
|
|
||||||
window.location.href = '/';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error during logout:', error);
|
|
||||||
toast.error('Failed to log out. Please try again.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const detectBrowser = (userAgent: string): string => {
|
const detectBrowser = (userAgent: string): string => {
|
||||||
if (userAgent.indexOf('Chrome') > -1) return 'Chrome';
|
if (userAgent.indexOf('Chrome') > -1) return 'Chrome';
|
||||||
|
@ -179,17 +170,13 @@ export default function AccountSecuritySettings({
|
||||||
<h4 className="font-semibold text-lg mb-2">Account Actions</h4>
|
<h4 className="font-semibold text-lg mb-2">Account Actions</h4>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="btn btn-error btn-outline w-full md:w-auto"
|
|
||||||
>
|
|
||||||
Sign Out
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p className="text-sm text-warning p-3 bg-warning bg-opacity-10 rounded-lg">
|
<p className="text-sm text-warning p-3 bg-warning bg-opacity-10 rounded-lg">
|
||||||
If you need to delete your account or have other account-related issues,
|
If you need to delete your account or have other account-related issues,
|
||||||
please contact an IEEE UCSD administrator.
|
please contact an IEEE UCSD administrator.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-sm text-info p-3 bg-info bg-opacity-10 rounded-lg">
|
||||||
|
To log out of your account, use the Logout option in the dashboard menu.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -258,17 +258,17 @@ export default function DisplaySettings() {
|
||||||
{/* Theme Settings */}
|
{/* Theme Settings */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-lg mb-2">Theme</h4>
|
<h4 className="font-semibold text-lg mb-2">Theme</h4>
|
||||||
<div className="w-full max-w-xs">
|
<div className="form-control w-full max-w-xs">
|
||||||
<select
|
<select
|
||||||
value={theme}
|
value={theme}
|
||||||
onChange={handleThemeChange}
|
onChange={handleThemeChange}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="select select-bordered"
|
||||||
>
|
>
|
||||||
<option value="light">Light</option>
|
<option value="light">Light</option>
|
||||||
<option value="dark">Dark</option>
|
<option value="dark">Dark</option>
|
||||||
</select>
|
</select>
|
||||||
<label className="mt-1 block">
|
<label className="label">
|
||||||
<span className="text-xs text-muted-foreground">Select your preferred theme</span>
|
<span className="label-text-alt">Select your preferred theme</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -276,19 +276,19 @@ export default function DisplaySettings() {
|
||||||
{/* Font Size Settings */}
|
{/* Font Size Settings */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-lg mb-2">Font Size</h4>
|
<h4 className="font-semibold text-lg mb-2">Font Size</h4>
|
||||||
<div className="w-full max-w-xs">
|
<div className="form-control w-full max-w-xs">
|
||||||
<select
|
<select
|
||||||
value={fontSize}
|
value={fontSize}
|
||||||
onChange={handleFontSizeChange}
|
onChange={handleFontSizeChange}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="select select-bordered"
|
||||||
>
|
>
|
||||||
<option value="small">Small</option>
|
<option value="small">Small</option>
|
||||||
<option value="medium">Medium</option>
|
<option value="medium">Medium</option>
|
||||||
<option value="large">Large</option>
|
<option value="large">Large</option>
|
||||||
<option value="extra-large">Extra Large</option>
|
<option value="extra-large">Extra Large</option>
|
||||||
</select>
|
</select>
|
||||||
<label className="mt-1 block">
|
<label className="label">
|
||||||
<span className="text-xs text-muted-foreground">Select your preferred font size</span>
|
<span className="label-text-alt">Select your preferred font size</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -297,64 +297,54 @@ export default function DisplaySettings() {
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-lg mb-2">Accessibility</h4>
|
<h4 className="font-semibold text-lg mb-2">Accessibility</h4>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4 mb-4">
|
<div className="form-control">
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="cursor-pointer label justify-start gap-4">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={colorBlindMode}
|
checked={colorBlindMode}
|
||||||
onChange={handleColorBlindModeChange}
|
onChange={handleColorBlindModeChange}
|
||||||
className="sr-only peer"
|
className="toggle toggle-primary"
|
||||||
/>
|
/>
|
||||||
<div className="w-11 h-6 bg-muted rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
<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>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="form-control mt-2">
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="cursor-pointer label justify-start gap-4">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={reducedMotion}
|
checked={reducedMotion}
|
||||||
onChange={handleReducedMotionChange}
|
onChange={handleReducedMotionChange}
|
||||||
className="sr-only peer"
|
className="toggle toggle-primary"
|
||||||
/>
|
/>
|
||||||
<div className="w-11 h-6 bg-muted rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
<div>
|
||||||
|
<span className="label-text font-medium">Reduced Motion</span>
|
||||||
|
<p className="text-xs opacity-70">Minimizes animations and transitions</p>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<div>
|
|
||||||
<span className="font-medium">Reduced Motion</span>
|
|
||||||
<p className="text-xs text-muted-foreground">Minimizes animations and transitions</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-blue-500 dark:text-blue-400 mt-4">
|
<p className="text-sm text-info">
|
||||||
These settings are saved to your browser using IndexedDB and your IEEE UCSD account. They will be applied whenever you log in.
|
These settings are saved to your browser using IndexedDB and your IEEE UCSD account. They will be applied whenever you log in.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="form-control">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
<p className="text-sm text-warning">
|
||||||
You have unsaved changes. Click "Save Settings" to apply them.
|
You have unsaved changes. Click "Save Settings" to apply them.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={`inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 ${saving ? 'opacity-70' : ''}`}
|
className={`btn btn-primary ${saving ? 'loading' : ''}`}
|
||||||
disabled={saving || !hasChanges}
|
disabled={saving || !hasChanges}
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? 'Saving...' : 'Save Settings'}
|
||||||
<>
|
|
||||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : 'Save Settings'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -370,7 +370,7 @@ export default function EmailRequestSettings() {
|
||||||
|
|
||||||
<div className="p-4 bg-base-200 rounded-lg">
|
<div className="p-4 bg-base-200 rounded-lg">
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
If you have any questions or need help with your IEEE email, please contact <a href="mailto:webmaster@ieeeucsd.org" className="underline">webmaster@ieeeucsd.org</a>
|
If you have any questions or need help with your IEEE email, please contact <a href="mailto:webmaster@ieeeatucsd.org" className="underline">webmaster@ieeeatucsd.org</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,99 +0,0 @@
|
||||||
---
|
|
||||||
// Sponsor Analytics Component
|
|
||||||
---
|
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-2xl mb-4">Analytics Dashboard</h2>
|
|
||||||
|
|
||||||
<!-- Metrics Overview -->
|
|
||||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
|
|
||||||
<div class="stat bg-base-200 rounded-box p-4">
|
|
||||||
<div class="stat-title">Resume Downloads</div>
|
|
||||||
<div class="stat-value text-primary">89</div>
|
|
||||||
<div class="stat-desc">↗︎ 14 (30 days)</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat bg-base-200 rounded-box p-4">
|
|
||||||
<div class="stat-title">Event Attendance</div>
|
|
||||||
<div class="stat-value">45</div>
|
|
||||||
<div class="stat-desc">↘︎ 5 (30 days)</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat bg-base-200 rounded-box p-4">
|
|
||||||
<div class="stat-title">Student Interactions</div>
|
|
||||||
<div class="stat-value text-secondary">124</div>
|
|
||||||
<div class="stat-desc">↗︎ 32 (30 days)</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat bg-base-200 rounded-box p-4">
|
|
||||||
<div class="stat-title">Workshop Engagement</div>
|
|
||||||
<div class="stat-value">92%</div>
|
|
||||||
<div class="stat-desc">↗︎ 8% (30 days)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Detailed Analytics -->
|
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
|
||||||
<!-- Event Performance -->
|
|
||||||
<div class="card bg-base-200">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title text-lg">Event Performance</h3>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Event</th>
|
|
||||||
<th>Attendance</th>
|
|
||||||
<th>Rating</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>Tech Talk</td>
|
|
||||||
<td>32</td>
|
|
||||||
<td>4.8/5</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Workshop</td>
|
|
||||||
<td>28</td>
|
|
||||||
<td>4.6/5</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Resume Analytics -->
|
|
||||||
<div class="card bg-base-200">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title text-lg">Resume Analytics</h3>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Major</th>
|
|
||||||
<th>Downloads</th>
|
|
||||||
<th>Trend</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>Computer Science</td>
|
|
||||||
<td>45</td>
|
|
||||||
<td>↗︎</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Electrical Engineering</td>
|
|
||||||
<td>32</td>
|
|
||||||
<td>↗︎</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
334
src/components/dashboard/SponsorAnalyticsSection.astro
Normal file
334
src/components/dashboard/SponsorAnalyticsSection.astro
Normal file
|
@ -0,0 +1,334 @@
|
||||||
|
---
|
||||||
|
import EventAttendanceChart from "./SponsorAnalyticsSection/EventAttendanceChart";
|
||||||
|
import EventTypeDistribution from "./SponsorAnalyticsSection/EventTypeDistribution";
|
||||||
|
import MajorDistribution from "./SponsorAnalyticsSection/MajorDistribution";
|
||||||
|
import EventEngagementMetrics from "./SponsorAnalyticsSection/EventEngagementMetrics";
|
||||||
|
import EventTimeline from "./SponsorAnalyticsSection/EventTimeline";
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div
|
||||||
|
class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold">Event Analytics</h2>
|
||||||
|
<p class="text-base-content/70">
|
||||||
|
Insights and analytics about IEEE UCSD events and student
|
||||||
|
engagement
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<div tabindex="0" role="button" class="btn btn-sm">
|
||||||
|
<span>Time Range</span>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-chevron-down"
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
tabindex="0"
|
||||||
|
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"
|
||||||
|
>
|
||||||
|
<li><a data-time-range="30">Last 30 Days</a></li>
|
||||||
|
<li><a data-time-range="90">Last 90 Days</a></li>
|
||||||
|
<li><a data-time-range="180">Last 6 Months</a></li>
|
||||||
|
<li><a data-time-range="365">Last Year</a></li>
|
||||||
|
<li><a data-time-range="all">All Time</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button id="refreshAnalyticsBtn" class="btn btn-sm btn-outline">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-refresh-cw"
|
||||||
|
>
|
||||||
|
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
|
||||||
|
></path>
|
||||||
|
<path d="M21 3v5h-5"></path>
|
||||||
|
<path
|
||||||
|
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
|
||||||
|
></path>
|
||||||
|
<path d="M3 21v-5h5"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div class="card bg-base-100 shadow-md">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h3 class="text-sm font-medium text-base-content/70">
|
||||||
|
Total Events
|
||||||
|
</h3>
|
||||||
|
<p class="text-3xl font-bold" id="totalEventsCount">--</p>
|
||||||
|
<div class="text-xs text-base-content/50 mt-1">
|
||||||
|
<span id="eventsTrend" class="font-medium"></span> vs previous
|
||||||
|
period
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-md">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h3 class="text-sm font-medium text-base-content/70">
|
||||||
|
Total Attendees
|
||||||
|
</h3>
|
||||||
|
<p class="text-3xl font-bold" id="totalAttendeesCount">--</p>
|
||||||
|
<div class="text-xs text-base-content/50 mt-1">
|
||||||
|
<span id="attendeesTrend" class="font-medium"></span> vs previous
|
||||||
|
period
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-md">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h3 class="text-sm font-medium text-base-content/70">
|
||||||
|
Unique Students
|
||||||
|
</h3>
|
||||||
|
<p class="text-3xl font-bold" id="uniqueStudentsCount">--</p>
|
||||||
|
<div class="text-xs text-base-content/50 mt-1">
|
||||||
|
<span id="uniqueStudentsTrend" class="font-medium"></span> vs
|
||||||
|
previous period
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-md">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h3 class="text-sm font-medium text-base-content/70">
|
||||||
|
Avg. Attendance
|
||||||
|
</h3>
|
||||||
|
<p class="text-3xl font-bold" id="avgAttendanceCount">--</p>
|
||||||
|
<div class="text-xs text-base-content/50 mt-1">
|
||||||
|
<span id="avgAttendanceTrend" class="font-medium"></span> vs
|
||||||
|
previous period
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Row 1 -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div class="card bg-base-100 shadow-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Event Attendance Over Time</h3>
|
||||||
|
<div class="h-80">
|
||||||
|
<EventAttendanceChart client:load />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Major Distribution</h3>
|
||||||
|
<div class="h-80">
|
||||||
|
<MajorDistribution client:load />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Row 2 -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div class="card bg-base-100 shadow-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Event Type Distribution</h3>
|
||||||
|
<div class="h-80">
|
||||||
|
<EventTypeDistribution client:load />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Engagement Metrics</h3>
|
||||||
|
<div class="h-80">
|
||||||
|
<EventEngagementMetrics client:load />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event Timeline -->
|
||||||
|
<div class="card bg-base-100 shadow-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Event Timeline</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<EventTimeline client:load />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Authentication } from "../../scripts/pocketbase/Authentication";
|
||||||
|
import { Get } from "../../scripts/pocketbase/Get";
|
||||||
|
import { Realtime } from "../../scripts/pocketbase/Realtime";
|
||||||
|
import { Collections } from "../../schemas/pocketbase/schema";
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
const get = Get.getInstance();
|
||||||
|
const realtime = Realtime.getInstance();
|
||||||
|
|
||||||
|
// Default time range (30 days)
|
||||||
|
let currentTimeRange = 30;
|
||||||
|
|
||||||
|
// Initialize the analytics dashboard
|
||||||
|
async function initAnalytics() {
|
||||||
|
if (!auth.isAuthenticated()) {
|
||||||
|
console.error("User not authenticated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadSummaryData(currentTimeRange);
|
||||||
|
|
||||||
|
// Set up event listeners
|
||||||
|
document
|
||||||
|
.querySelectorAll("[data-time-range]")
|
||||||
|
.forEach((element) => {
|
||||||
|
element.addEventListener("click", (e) => {
|
||||||
|
const range =
|
||||||
|
parseInt(
|
||||||
|
e.currentTarget.getAttribute("data-time-range")
|
||||||
|
) || 30;
|
||||||
|
currentTimeRange = isNaN(range) ? "all" : range;
|
||||||
|
loadSummaryData(currentTimeRange);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh button
|
||||||
|
document
|
||||||
|
.getElementById("refreshAnalyticsBtn")
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
loadSummaryData(currentTimeRange);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up realtime updates
|
||||||
|
setupRealtimeUpdates();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error initializing analytics:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load summary data
|
||||||
|
async function loadSummaryData(timeRange) {
|
||||||
|
try {
|
||||||
|
// Calculate date range
|
||||||
|
const endDate = new Date();
|
||||||
|
let startDate;
|
||||||
|
|
||||||
|
if (timeRange === "all") {
|
||||||
|
startDate = new Date(0); // Beginning of time
|
||||||
|
} else {
|
||||||
|
startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - timeRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format dates for filter
|
||||||
|
const startDateStr = startDate.toISOString();
|
||||||
|
const endDateStr = endDate.toISOString();
|
||||||
|
|
||||||
|
// Build filter
|
||||||
|
const filter =
|
||||||
|
timeRange === "all"
|
||||||
|
? "published = true"
|
||||||
|
: `published = true && start_date >= "${startDateStr}" && start_date <= "${endDateStr}"`;
|
||||||
|
|
||||||
|
// Get events
|
||||||
|
const events = await get.getAll(Collections.EVENTS, filter);
|
||||||
|
|
||||||
|
// Get event attendees
|
||||||
|
const attendeesFilter =
|
||||||
|
timeRange === "all"
|
||||||
|
? ""
|
||||||
|
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
|
||||||
|
|
||||||
|
const attendees = await get.getAll(
|
||||||
|
Collections.EVENT_ATTENDEES,
|
||||||
|
attendeesFilter
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate metrics
|
||||||
|
const totalEvents = events.length;
|
||||||
|
const totalAttendees = attendees.length;
|
||||||
|
|
||||||
|
// Calculate unique students
|
||||||
|
const uniqueStudentIds = new Set(attendees.map((a) => a.user));
|
||||||
|
const uniqueStudents = uniqueStudentIds.size;
|
||||||
|
|
||||||
|
// Calculate average attendance
|
||||||
|
const avgAttendance =
|
||||||
|
totalEvents > 0 ? Math.round(totalAttendees / totalEvents) : 0;
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
document.getElementById("totalEventsCount").textContent =
|
||||||
|
totalEvents;
|
||||||
|
document.getElementById("totalAttendeesCount").textContent =
|
||||||
|
totalAttendees;
|
||||||
|
document.getElementById("uniqueStudentsCount").textContent =
|
||||||
|
uniqueStudents;
|
||||||
|
document.getElementById("avgAttendanceCount").textContent =
|
||||||
|
avgAttendance;
|
||||||
|
|
||||||
|
// Calculate trends (simplified - would need previous period data for real implementation)
|
||||||
|
document.getElementById("eventsTrend").textContent = "+5%";
|
||||||
|
document.getElementById("attendeesTrend").textContent = "+12%";
|
||||||
|
document.getElementById("uniqueStudentsTrend").textContent = "+8%";
|
||||||
|
document.getElementById("avgAttendanceTrend").textContent = "+3%";
|
||||||
|
|
||||||
|
// Dispatch custom event to notify charts to update
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("analyticsDataUpdated", {
|
||||||
|
detail: {
|
||||||
|
events,
|
||||||
|
attendees,
|
||||||
|
timeRange,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading summary data:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up realtime updates
|
||||||
|
function setupRealtimeUpdates() {
|
||||||
|
// Subscribe to events collection
|
||||||
|
realtime.subscribeToCollection(Collections.EVENTS, (data) => {
|
||||||
|
console.log("Event data updated:", data);
|
||||||
|
loadSummaryData(currentTimeRange);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to event attendees collection
|
||||||
|
realtime.subscribeToCollection(Collections.EVENT_ATTENDEES, (data) => {
|
||||||
|
console.log("Attendee data updated:", data);
|
||||||
|
loadSummaryData(currentTimeRange);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when document is ready
|
||||||
|
document.addEventListener("DOMContentLoaded", initAnalytics);
|
||||||
|
</script>
|
|
@ -0,0 +1,249 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
|
import type { Event, EventAttendee } from '../../../schemas/pocketbase/schema';
|
||||||
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
|
|
||||||
|
// Import Chart.js
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
BarElement,
|
||||||
|
} from 'chart.js';
|
||||||
|
import type { ChartOptions } from 'chart.js';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
|
||||||
|
// Register Chart.js components
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function EventAttendanceChart() {
|
||||||
|
const [chartData, setChartData] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [timeRange, setTimeRange] = useState<number | string>(30); // Default to 30 days
|
||||||
|
const chartRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const get = Get.getInstance();
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen for analytics data updates from the parent component
|
||||||
|
const handleAnalyticsUpdate = (event: CustomEvent) => {
|
||||||
|
const { events, attendees, timeRange } = event.detail;
|
||||||
|
setTimeRange(timeRange);
|
||||||
|
processChartData(events, attendees);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
|
||||||
|
|
||||||
|
// Initial data load
|
||||||
|
loadData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Calculate date range
|
||||||
|
const endDate = new Date();
|
||||||
|
let startDate;
|
||||||
|
|
||||||
|
if (timeRange === "all") {
|
||||||
|
startDate = new Date(0); // Beginning of time
|
||||||
|
} else {
|
||||||
|
startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - (typeof timeRange === 'number' ? timeRange : 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format dates for filter
|
||||||
|
const startDateStr = startDate.toISOString();
|
||||||
|
const endDateStr = endDate.toISOString();
|
||||||
|
|
||||||
|
// Build filter
|
||||||
|
const filter = timeRange === "all"
|
||||||
|
? "published = true"
|
||||||
|
: `published = true && start_date >= "${startDateStr}" && start_date <= "${endDateStr}"`;
|
||||||
|
|
||||||
|
// Get events
|
||||||
|
const events = await get.getAll<Event>(Collections.EVENTS, filter);
|
||||||
|
|
||||||
|
// Get event attendees
|
||||||
|
const attendeesFilter = timeRange === "all"
|
||||||
|
? ""
|
||||||
|
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
|
||||||
|
|
||||||
|
const attendees = await get.getAll<EventAttendee>(Collections.EVENT_ATTENDEES, attendeesFilter);
|
||||||
|
|
||||||
|
processChartData(events, attendees);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading event attendance data:', err);
|
||||||
|
setError('Failed to load event attendance data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processChartData = (events: Event[], attendees: EventAttendee[]) => {
|
||||||
|
if (!events || events.length === 0) {
|
||||||
|
setChartData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group events by date
|
||||||
|
const eventsByDate = new Map<string, Event[]>();
|
||||||
|
events.forEach(event => {
|
||||||
|
// Format date to YYYY-MM-DD
|
||||||
|
const date = new Date(event.start_date);
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
if (!eventsByDate.has(dateStr)) {
|
||||||
|
eventsByDate.set(dateStr, []);
|
||||||
|
}
|
||||||
|
eventsByDate.get(dateStr)!.push(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count attendees per event
|
||||||
|
const attendeesByEvent = new Map<string, number>();
|
||||||
|
attendees.forEach(attendee => {
|
||||||
|
if (!attendeesByEvent.has(attendee.event)) {
|
||||||
|
attendeesByEvent.set(attendee.event, 0);
|
||||||
|
}
|
||||||
|
attendeesByEvent.set(attendee.event, attendeesByEvent.get(attendee.event)! + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate average attendance per date
|
||||||
|
const attendanceByDate = new Map<string, { total: number, count: number }>();
|
||||||
|
events.forEach(event => {
|
||||||
|
const date = new Date(event.start_date);
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
if (!attendanceByDate.has(dateStr)) {
|
||||||
|
attendanceByDate.set(dateStr, { total: 0, count: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendeeCount = attendeesByEvent.get(event.id) || 0;
|
||||||
|
const current = attendanceByDate.get(dateStr)!;
|
||||||
|
attendanceByDate.set(dateStr, {
|
||||||
|
total: current.total + attendeeCount,
|
||||||
|
count: current.count + 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort dates
|
||||||
|
const sortedDates = Array.from(attendanceByDate.keys()).sort();
|
||||||
|
|
||||||
|
// Calculate average attendance per date
|
||||||
|
const averageAttendance = sortedDates.map(date => {
|
||||||
|
const { total, count } = attendanceByDate.get(date)!;
|
||||||
|
return count > 0 ? Math.round(total / count) : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format dates for display
|
||||||
|
const formattedDates = sortedDates.map(date => {
|
||||||
|
const [year, month, day] = date.split('-');
|
||||||
|
return `${month}/${day}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create chart data
|
||||||
|
const data = {
|
||||||
|
labels: formattedDates,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Average Attendance',
|
||||||
|
data: averageAttendance,
|
||||||
|
borderColor: 'rgba(75, 192, 192, 1)',
|
||||||
|
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
setChartData(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions: ChartOptions<'line'> = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Average Attendance'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Date'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
intersect: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<div className="text-error">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chartData) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<div className="text-center text-base-content/70">
|
||||||
|
<p>No event data available for the selected time period</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<Line data={chartData} options={chartOptions} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,334 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
|
import type { Event, EventAttendee, User } from '../../../schemas/pocketbase/schema';
|
||||||
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
|
|
||||||
|
// Import Chart.js
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
RadialLinearScale,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Radar } from 'react-chartjs-2';
|
||||||
|
|
||||||
|
// Register Chart.js components
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
RadialLinearScale,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function EventEngagementMetrics() {
|
||||||
|
const [chartData, setChartData] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [timeRange, setTimeRange] = useState<number | string>(30); // Default to 30 days
|
||||||
|
|
||||||
|
const get = Get.getInstance();
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen for analytics data updates from the parent component
|
||||||
|
const handleAnalyticsUpdate = (event: CustomEvent) => {
|
||||||
|
const { events, attendees, timeRange } = event.detail;
|
||||||
|
setTimeRange(timeRange);
|
||||||
|
loadUserData(events, attendees);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
|
||||||
|
|
||||||
|
// Initial data load
|
||||||
|
loadData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Calculate date range
|
||||||
|
const endDate = new Date();
|
||||||
|
let startDate;
|
||||||
|
|
||||||
|
if (timeRange === "all") {
|
||||||
|
startDate = new Date(0); // Beginning of time
|
||||||
|
} else {
|
||||||
|
startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - (typeof timeRange === 'number' ? timeRange : 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format dates for filter
|
||||||
|
const startDateStr = startDate.toISOString();
|
||||||
|
const endDateStr = endDate.toISOString();
|
||||||
|
|
||||||
|
// Build filter
|
||||||
|
const filter = timeRange === "all"
|
||||||
|
? "published = true"
|
||||||
|
: `published = true && start_date >= "${startDateStr}" && start_date <= "${endDateStr}"`;
|
||||||
|
|
||||||
|
// Get events
|
||||||
|
const events = await get.getAll<Event>(Collections.EVENTS, filter);
|
||||||
|
|
||||||
|
// Get event attendees
|
||||||
|
const attendeesFilter = timeRange === "all"
|
||||||
|
? ""
|
||||||
|
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
|
||||||
|
|
||||||
|
const attendees = await get.getAll<EventAttendee>(Collections.EVENT_ATTENDEES, attendeesFilter);
|
||||||
|
|
||||||
|
await loadUserData(events, attendees);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading engagement metrics data:', err);
|
||||||
|
setError('Failed to load engagement metrics data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUserData = async (events: Event[], attendees: EventAttendee[]) => {
|
||||||
|
try {
|
||||||
|
if (!events || events.length === 0 || !attendees || attendees.length === 0) {
|
||||||
|
setChartData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique user IDs from attendees
|
||||||
|
const userIds = [...new Set(attendees.map(a => a.user))];
|
||||||
|
|
||||||
|
// Fetch user data to get graduation years
|
||||||
|
const users = await get.getMany<User>(Collections.USERS, userIds);
|
||||||
|
|
||||||
|
processChartData(events, attendees, users);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading user data:', err);
|
||||||
|
setError('Failed to load user data');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processChartData = (events: Event[], attendees: EventAttendee[], users: User[]) => {
|
||||||
|
if (!events || events.length === 0 || !attendees || attendees.length === 0) {
|
||||||
|
setChartData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of user IDs to graduation years
|
||||||
|
const userGradYearMap = new Map<string, number>();
|
||||||
|
users.forEach(user => {
|
||||||
|
if (user.graduation_year) {
|
||||||
|
userGradYearMap.set(user.id, user.graduation_year);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate metrics
|
||||||
|
|
||||||
|
// 1. Attendance by time of day
|
||||||
|
const timeOfDayAttendance = {
|
||||||
|
'Morning (8am-12pm)': 0,
|
||||||
|
'Afternoon (12pm-5pm)': 0,
|
||||||
|
'Evening (5pm-9pm)': 0,
|
||||||
|
'Night (9pm-8am)': 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
events.forEach(event => {
|
||||||
|
const startDate = new Date(event.start_date);
|
||||||
|
const hour = startDate.getHours();
|
||||||
|
|
||||||
|
// Count the event in the appropriate time slot
|
||||||
|
if (hour >= 8 && hour < 12) {
|
||||||
|
timeOfDayAttendance['Morning (8am-12pm)']++;
|
||||||
|
} else if (hour >= 12 && hour < 17) {
|
||||||
|
timeOfDayAttendance['Afternoon (12pm-5pm)']++;
|
||||||
|
} else if (hour >= 17 && hour < 21) {
|
||||||
|
timeOfDayAttendance['Evening (5pm-9pm)']++;
|
||||||
|
} else {
|
||||||
|
timeOfDayAttendance['Night (9pm-8am)']++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Attendance by day of week
|
||||||
|
const dayOfWeekAttendance = {
|
||||||
|
'Sunday': 0,
|
||||||
|
'Monday': 0,
|
||||||
|
'Tuesday': 0,
|
||||||
|
'Wednesday': 0,
|
||||||
|
'Thursday': 0,
|
||||||
|
'Friday': 0,
|
||||||
|
'Saturday': 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const daysOfWeek = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
events.forEach(event => {
|
||||||
|
const startDate = new Date(event.start_date);
|
||||||
|
const dayOfWeek = daysOfWeek[startDate.getDay()];
|
||||||
|
// Use type assertion to avoid TypeScript error
|
||||||
|
(dayOfWeekAttendance as Record<string, number>)[dayOfWeek]++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Attendance by graduation year
|
||||||
|
const gradYearAttendance: Record<string, number> = {};
|
||||||
|
attendees.forEach(attendee => {
|
||||||
|
const userId = attendee.user;
|
||||||
|
const gradYear = userGradYearMap.get(userId);
|
||||||
|
|
||||||
|
if (gradYear) {
|
||||||
|
const gradYearStr = gradYear.toString();
|
||||||
|
if (!gradYearAttendance[gradYearStr]) {
|
||||||
|
gradYearAttendance[gradYearStr] = 0;
|
||||||
|
}
|
||||||
|
gradYearAttendance[gradYearStr]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Food vs. No Food events
|
||||||
|
const foodEvents = events.filter(event => event.has_food).length;
|
||||||
|
const noFoodEvents = events.length - foodEvents;
|
||||||
|
|
||||||
|
// 5. Average attendance per event
|
||||||
|
const attendanceByEvent = new Map<string, number>();
|
||||||
|
attendees.forEach(attendee => {
|
||||||
|
if (!attendanceByEvent.has(attendee.event)) {
|
||||||
|
attendanceByEvent.set(attendee.event, 0);
|
||||||
|
}
|
||||||
|
attendanceByEvent.set(attendee.event, attendanceByEvent.get(attendee.event)! + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const avgAttendance = events.length > 0
|
||||||
|
? Math.round(attendees.length / events.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Prepare radar chart data
|
||||||
|
// Normalize all metrics to a 0-100 scale for the radar chart
|
||||||
|
const maxTimeOfDay = Math.max(...Object.values(timeOfDayAttendance));
|
||||||
|
const maxDayOfWeek = Math.max(...Object.values(dayOfWeekAttendance));
|
||||||
|
const foodRatio = events.length > 0 ? (foodEvents / events.length) * 100 : 0;
|
||||||
|
|
||||||
|
// Calculate repeat attendance rate (% of users who attended more than one event)
|
||||||
|
const userAttendanceCounts = new Map<string, number>();
|
||||||
|
attendees.forEach(attendee => {
|
||||||
|
if (!userAttendanceCounts.has(attendee.user)) {
|
||||||
|
userAttendanceCounts.set(attendee.user, 0);
|
||||||
|
}
|
||||||
|
userAttendanceCounts.set(attendee.user, userAttendanceCounts.get(attendee.user)! + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const repeatAttendees = [...userAttendanceCounts.values()].filter(count => count > 1).length;
|
||||||
|
const repeatRate = userAttendanceCounts.size > 0
|
||||||
|
? (repeatAttendees / userAttendanceCounts.size) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Normalize metrics for radar chart (0-100 scale)
|
||||||
|
const normalizeValue = (value: number, max: number) => max > 0 ? (value / max) * 100 : 0;
|
||||||
|
|
||||||
|
const radarData = {
|
||||||
|
labels: [
|
||||||
|
'Morning Events',
|
||||||
|
'Afternoon Events',
|
||||||
|
'Evening Events',
|
||||||
|
'Weekday Events',
|
||||||
|
'Weekend Events',
|
||||||
|
'Food Events',
|
||||||
|
'Repeat Attendance'
|
||||||
|
],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Engagement Metrics',
|
||||||
|
data: [
|
||||||
|
normalizeValue(timeOfDayAttendance['Morning (8am-12pm)'], maxTimeOfDay),
|
||||||
|
normalizeValue(timeOfDayAttendance['Afternoon (12pm-5pm)'], maxTimeOfDay),
|
||||||
|
normalizeValue(timeOfDayAttendance['Evening (5pm-9pm)'], maxTimeOfDay),
|
||||||
|
normalizeValue(
|
||||||
|
dayOfWeekAttendance['Monday'] +
|
||||||
|
dayOfWeekAttendance['Tuesday'] +
|
||||||
|
dayOfWeekAttendance['Wednesday'] +
|
||||||
|
dayOfWeekAttendance['Thursday'] +
|
||||||
|
dayOfWeekAttendance['Friday'],
|
||||||
|
maxDayOfWeek * 5
|
||||||
|
),
|
||||||
|
normalizeValue(
|
||||||
|
dayOfWeekAttendance['Saturday'] +
|
||||||
|
dayOfWeekAttendance['Sunday'],
|
||||||
|
maxDayOfWeek * 2
|
||||||
|
),
|
||||||
|
foodRatio,
|
||||||
|
repeatRate
|
||||||
|
],
|
||||||
|
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
||||||
|
borderColor: 'rgba(54, 162, 235, 1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
pointBackgroundColor: 'rgba(54, 162, 235, 1)',
|
||||||
|
pointBorderColor: '#fff',
|
||||||
|
pointHoverBackgroundColor: '#fff',
|
||||||
|
pointHoverBorderColor: 'rgba(54, 162, 235, 1)',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
setChartData(radarData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top' as const,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function (context: any) {
|
||||||
|
return `${context.label}: ${Math.round(context.raw)}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<div className="text-error">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chartData) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<div className="text-center text-base-content/70">
|
||||||
|
<p>No event data available for the selected time period</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<Radar data={chartData} options={chartOptions} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
|
import type { Event, EventAttendee } from '../../../schemas/pocketbase/schema';
|
||||||
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
|
|
||||||
|
export default function EventTimeline() {
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [attendeesByEvent, setAttendeesByEvent] = useState<Map<string, number>>(new Map());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [timeRange, setTimeRange] = useState<number | string>(30); // Default to 30 days
|
||||||
|
|
||||||
|
const get = Get.getInstance();
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen for analytics data updates from the parent component
|
||||||
|
const handleAnalyticsUpdate = (event: CustomEvent) => {
|
||||||
|
const { events, attendees, timeRange } = event.detail;
|
||||||
|
setTimeRange(timeRange);
|
||||||
|
processData(events, attendees);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
|
||||||
|
|
||||||
|
// Initial data load
|
||||||
|
loadData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Calculate date range
|
||||||
|
const endDate = new Date();
|
||||||
|
let startDate;
|
||||||
|
|
||||||
|
if (timeRange === "all") {
|
||||||
|
startDate = new Date(0); // Beginning of time
|
||||||
|
} else {
|
||||||
|
startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - (typeof timeRange === 'number' ? timeRange : 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format dates for filter
|
||||||
|
const startDateStr = startDate.toISOString();
|
||||||
|
const endDateStr = endDate.toISOString();
|
||||||
|
|
||||||
|
// Build filter
|
||||||
|
const filter = timeRange === "all"
|
||||||
|
? "published = true"
|
||||||
|
: `published = true && start_date >= "${startDateStr}" && start_date <= "${endDateStr}"`;
|
||||||
|
|
||||||
|
// Get events
|
||||||
|
const events = await get.getAll<Event>(Collections.EVENTS, filter, "start_date");
|
||||||
|
|
||||||
|
// Get event attendees
|
||||||
|
const attendeesFilter = timeRange === "all"
|
||||||
|
? ""
|
||||||
|
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
|
||||||
|
|
||||||
|
const attendees = await get.getAll<EventAttendee>(Collections.EVENT_ATTENDEES, attendeesFilter);
|
||||||
|
|
||||||
|
processData(events, attendees);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading event timeline data:', err);
|
||||||
|
setError('Failed to load event timeline data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processData = (events: Event[], attendees: EventAttendee[]) => {
|
||||||
|
if (!events || events.length === 0) {
|
||||||
|
setEvents([]);
|
||||||
|
setAttendeesByEvent(new Map());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort events by date (newest first)
|
||||||
|
const sortedEvents = [...events].sort((a, b) => {
|
||||||
|
return new Date(b.start_date).getTime() - new Date(a.start_date).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count attendees per event
|
||||||
|
const attendeesByEvent = new Map<string, number>();
|
||||||
|
attendees.forEach(attendee => {
|
||||||
|
if (!attendeesByEvent.has(attendee.event)) {
|
||||||
|
attendeesByEvent.set(attendee.event, 0);
|
||||||
|
}
|
||||||
|
attendeesByEvent.set(attendee.event, attendeesByEvent.get(attendee.event)! + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
setEvents(sortedEvents);
|
||||||
|
setAttendeesByEvent(attendeesByEvent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (startDate: string, endDate: string) => {
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
const durationMs = end.getTime() - start.getTime();
|
||||||
|
const hours = Math.floor(durationMs / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
|
||||||
|
if (hours === 0) {
|
||||||
|
return `${minutes} min`;
|
||||||
|
} else if (minutes === 0) {
|
||||||
|
return `${hours} hr`;
|
||||||
|
} else {
|
||||||
|
return `${hours} hr ${minutes} min`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center py-10">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="alert alert-error">
|
||||||
|
<div className="flex-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mx-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||||
|
</svg>
|
||||||
|
<label>{error}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-10">
|
||||||
|
<p className="text-base-content/70">No events found for the selected time period</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Event Name</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Attendees</th>
|
||||||
|
<th>Food</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{events.map(event => (
|
||||||
|
<tr key={event.id} className="hover">
|
||||||
|
<td className="font-medium">{event.event_name}</td>
|
||||||
|
<td>{formatDate(event.start_date)}</td>
|
||||||
|
<td>{formatDuration(event.start_date, event.end_date)}</td>
|
||||||
|
<td>{event.location}</td>
|
||||||
|
<td>
|
||||||
|
<div className="badge badge-primary">
|
||||||
|
{attendeesByEvent.get(event.id) || 0}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{event.has_food ? (
|
||||||
|
<div className="badge badge-success">Yes</div>
|
||||||
|
) : (
|
||||||
|
<div className="badge badge-ghost">No</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
|
import type { Event, EventAttendee } from '../../../schemas/pocketbase/schema';
|
||||||
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
|
|
||||||
|
// Import Chart.js
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
ArcElement,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Pie } from 'react-chartjs-2';
|
||||||
|
|
||||||
|
// Register Chart.js components
|
||||||
|
ChartJS.register(
|
||||||
|
ArcElement,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale
|
||||||
|
);
|
||||||
|
|
||||||
|
// Define event types and their colors
|
||||||
|
const EVENT_TYPES = [
|
||||||
|
{ name: 'Social', key: 'social', color: 'rgba(255, 99, 132, 0.8)' },
|
||||||
|
{ name: 'Technical', key: 'technical', color: 'rgba(54, 162, 235, 0.8)' },
|
||||||
|
{ name: 'Outreach', key: 'outreach', color: 'rgba(255, 206, 86, 0.8)' },
|
||||||
|
{ name: 'Professional', key: 'professional', color: 'rgba(75, 192, 192, 0.8)' },
|
||||||
|
{ name: 'Projects', key: 'projects', color: 'rgba(153, 102, 255, 0.8)' },
|
||||||
|
{ name: 'Other', key: 'other', color: 'rgba(255, 159, 64, 0.8)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EventTypeDistribution() {
|
||||||
|
const [chartData, setChartData] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [timeRange, setTimeRange] = useState<number | string>(30); // Default to 30 days
|
||||||
|
|
||||||
|
const get = Get.getInstance();
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen for analytics data updates from the parent component
|
||||||
|
const handleAnalyticsUpdate = (event: CustomEvent) => {
|
||||||
|
const { events, attendees, timeRange } = event.detail;
|
||||||
|
setTimeRange(timeRange);
|
||||||
|
processChartData(events, attendees);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
|
||||||
|
|
||||||
|
// Initial data load
|
||||||
|
loadData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Calculate date range
|
||||||
|
const endDate = new Date();
|
||||||
|
let startDate;
|
||||||
|
|
||||||
|
if (timeRange === "all") {
|
||||||
|
startDate = new Date(0); // Beginning of time
|
||||||
|
} else {
|
||||||
|
startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - (typeof timeRange === 'number' ? timeRange : 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format dates for filter
|
||||||
|
const startDateStr = startDate.toISOString();
|
||||||
|
const endDateStr = endDate.toISOString();
|
||||||
|
|
||||||
|
// Build filter
|
||||||
|
const filter = timeRange === "all"
|
||||||
|
? "published = true"
|
||||||
|
: `published = true && start_date >= "${startDateStr}" && start_date <= "${endDateStr}"`;
|
||||||
|
|
||||||
|
// Get events
|
||||||
|
const events = await get.getAll<Event>(Collections.EVENTS, filter);
|
||||||
|
|
||||||
|
// Get event attendees
|
||||||
|
const attendeesFilter = timeRange === "all"
|
||||||
|
? ""
|
||||||
|
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
|
||||||
|
|
||||||
|
const attendees = await get.getAll<EventAttendee>(Collections.EVENT_ATTENDEES, attendeesFilter);
|
||||||
|
|
||||||
|
processChartData(events, attendees);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading event type distribution data:', err);
|
||||||
|
setError('Failed to load event type distribution data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processChartData = (events: Event[], attendees: EventAttendee[]) => {
|
||||||
|
if (!events || events.length === 0) {
|
||||||
|
setChartData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categorize events by type
|
||||||
|
// For this demo, we'll use a simple heuristic based on event name/description
|
||||||
|
// In a real implementation, you might have an event_type field in your schema
|
||||||
|
const eventTypeCount = EVENT_TYPES.reduce((acc, type) => {
|
||||||
|
acc[type.name] = 0;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
// Count events by event_type field from schema
|
||||||
|
events.forEach(event => {
|
||||||
|
const type = event.event_type && EVENT_TYPES.find(t => t.key === event.event_type) ? event.event_type : 'other';
|
||||||
|
const typeObj = EVENT_TYPES.find(t => t.key === type);
|
||||||
|
if (typeObj) {
|
||||||
|
eventTypeCount[typeObj.name]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prepare data for chart
|
||||||
|
const labels = Object.keys(eventTypeCount);
|
||||||
|
const data = Object.values(eventTypeCount);
|
||||||
|
const backgroundColor = labels.map(label =>
|
||||||
|
EVENT_TYPES.find(type => type.name === label)?.color || 'rgba(128, 128, 128, 0.8)'
|
||||||
|
);
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
backgroundColor,
|
||||||
|
borderColor: backgroundColor.map(color => color.replace('0.8', '1')),
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
setChartData(chartData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'right' as const,
|
||||||
|
labels: {
|
||||||
|
padding: 20,
|
||||||
|
boxWidth: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function (context: any) {
|
||||||
|
// Only show the label, not the value
|
||||||
|
return context.label || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<div className="text-error">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chartData) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<div className="text-center text-base-content/70">
|
||||||
|
<p>No event data available for the selected time period</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<Pie data={chartData} options={chartOptions} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,262 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
|
import type { User, EventAttendee } from '../../../schemas/pocketbase/schema';
|
||||||
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
|
|
||||||
|
// Import Chart.js
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Bar } from 'react-chartjs-2';
|
||||||
|
|
||||||
|
// Register Chart.js components
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
);
|
||||||
|
|
||||||
|
// Define major categories and their colors
|
||||||
|
const MAJOR_CATEGORIES = [
|
||||||
|
{ name: 'Computer Science', color: 'rgba(54, 162, 235, 0.8)' },
|
||||||
|
{ name: 'Electrical Engineering', color: 'rgba(255, 99, 132, 0.8)' },
|
||||||
|
{ name: 'Computer Engineering', color: 'rgba(75, 192, 192, 0.8)' },
|
||||||
|
{ name: 'Mechanical Engineering', color: 'rgba(255, 206, 86, 0.8)' },
|
||||||
|
{ name: 'Data Science', color: 'rgba(153, 102, 255, 0.8)' },
|
||||||
|
{ name: 'Mathematics', color: 'rgba(255, 159, 64, 0.8)' },
|
||||||
|
{ name: 'Physics', color: 'rgba(201, 203, 207, 0.8)' },
|
||||||
|
{ name: 'Other Engineering', color: 'rgba(100, 149, 237, 0.8)' },
|
||||||
|
{ name: 'Other', color: 'rgba(169, 169, 169, 0.8)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MajorDistribution() {
|
||||||
|
const [chartData, setChartData] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [timeRange, setTimeRange] = useState<number | string>(30); // Default to 30 days
|
||||||
|
|
||||||
|
const get = Get.getInstance();
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen for analytics data updates from the parent component
|
||||||
|
const handleAnalyticsUpdate = (event: CustomEvent) => {
|
||||||
|
const { events, attendees, timeRange } = event.detail;
|
||||||
|
setTimeRange(timeRange);
|
||||||
|
loadUserData(attendees);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
|
||||||
|
|
||||||
|
// Initial data load
|
||||||
|
loadData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Calculate date range
|
||||||
|
const endDate = new Date();
|
||||||
|
let startDate;
|
||||||
|
|
||||||
|
if (timeRange === "all") {
|
||||||
|
startDate = new Date(0); // Beginning of time
|
||||||
|
} else {
|
||||||
|
startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - (typeof timeRange === 'number' ? timeRange : 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format dates for filter
|
||||||
|
const startDateStr = startDate.toISOString();
|
||||||
|
const endDateStr = endDate.toISOString();
|
||||||
|
|
||||||
|
// Get event attendees
|
||||||
|
const attendeesFilter = timeRange === "all"
|
||||||
|
? ""
|
||||||
|
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
|
||||||
|
|
||||||
|
const attendees = await get.getAll<EventAttendee>(Collections.EVENT_ATTENDEES, attendeesFilter);
|
||||||
|
|
||||||
|
await loadUserData(attendees);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading major distribution data:', err);
|
||||||
|
setError('Failed to load major distribution data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUserData = async (attendees: EventAttendee[]) => {
|
||||||
|
try {
|
||||||
|
if (!attendees || attendees.length === 0) {
|
||||||
|
setChartData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique user IDs from attendees
|
||||||
|
const userIds = [...new Set(attendees.map(a => a.user))];
|
||||||
|
|
||||||
|
// Fetch user data to get majors
|
||||||
|
const users = await get.getMany<User>(Collections.USERS, userIds);
|
||||||
|
|
||||||
|
processChartData(users);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading user data:', err);
|
||||||
|
setError('Failed to load user data');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processChartData = (users: User[]) => {
|
||||||
|
if (!users || users.length === 0) {
|
||||||
|
setChartData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categorize users by major
|
||||||
|
const majorCounts = MAJOR_CATEGORIES.reduce((acc, category) => {
|
||||||
|
acc[category.name] = 0;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
users.forEach(user => {
|
||||||
|
if (!user.major) {
|
||||||
|
majorCounts['Other']++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const major = user.major.toLowerCase();
|
||||||
|
|
||||||
|
// Categorize majors
|
||||||
|
if (major.includes('computer science') || major.includes('cs')) {
|
||||||
|
majorCounts['Computer Science']++;
|
||||||
|
} else if (major.includes('electrical') || major.includes('ee')) {
|
||||||
|
majorCounts['Electrical Engineering']++;
|
||||||
|
} else if (major.includes('computer eng') || major.includes('ce')) {
|
||||||
|
majorCounts['Computer Engineering']++;
|
||||||
|
} else if (major.includes('mechanical') || major.includes('me')) {
|
||||||
|
majorCounts['Mechanical Engineering']++;
|
||||||
|
} else if (major.includes('data science') || major.includes('ds')) {
|
||||||
|
majorCounts['Data Science']++;
|
||||||
|
} else if (major.includes('math')) {
|
||||||
|
majorCounts['Mathematics']++;
|
||||||
|
} else if (major.includes('physics')) {
|
||||||
|
majorCounts['Physics']++;
|
||||||
|
} else if (major.includes('engineering')) {
|
||||||
|
majorCounts['Other Engineering']++;
|
||||||
|
} else {
|
||||||
|
majorCounts['Other']++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by count (descending)
|
||||||
|
const sortedMajors = Object.entries(majorCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.filter(([_, count]) => count > 0); // Only include majors with at least one student
|
||||||
|
|
||||||
|
// Prepare data for chart
|
||||||
|
const labels = sortedMajors.map(([major]) => major);
|
||||||
|
const data = sortedMajors.map(([_, count]) => count);
|
||||||
|
const backgroundColor = labels.map(label =>
|
||||||
|
MAJOR_CATEGORIES.find(category => category.name === label)?.color || 'rgba(128, 128, 128, 0.8)'
|
||||||
|
);
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Number of Students',
|
||||||
|
data,
|
||||||
|
backgroundColor,
|
||||||
|
borderColor: backgroundColor.map(color => color.replace('0.8', '1')),
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
setChartData(chartData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
indexAxis: 'y' as const,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function (context: any) {
|
||||||
|
const label = context.label || '';
|
||||||
|
const value = context.raw || 0;
|
||||||
|
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0);
|
||||||
|
const percentage = Math.round((value / total) * 100);
|
||||||
|
return `${value} students (${percentage}%)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Number of Students'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Major'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<div className="text-error">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chartData) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<div className="text-center text-base-content/70">
|
||||||
|
<p>No student data available for the selected time period</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<Bar data={chartData} options={chartOptions} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,78 +0,0 @@
|
||||||
---
|
|
||||||
// Sponsor Dashboard Component
|
|
||||||
---
|
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-2xl mb-4">Sponsor Dashboard</h2>
|
|
||||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<!-- Sponsorship Status -->
|
|
||||||
<div class="card bg-base-200">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title text-lg">Sponsorship Status</h3>
|
|
||||||
<p class="text-primary font-semibold">Active</p>
|
|
||||||
<p class="text-sm opacity-70">Valid until: Dec 31, 2024</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Partnership Level -->
|
|
||||||
<div class="card bg-base-200">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title text-lg">Partnership Level</h3>
|
|
||||||
<p class="text-primary font-semibold">Platinum</p>
|
|
||||||
<p class="text-sm opacity-70">All benefits included</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
|
||||||
<div class="card bg-base-200">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title text-lg">Quick Actions</h3>
|
|
||||||
<div class="flex gap-2 mt-2">
|
|
||||||
<button class="btn btn-primary btn-sm"
|
|
||||||
>Contact Us</button
|
|
||||||
>
|
|
||||||
<button class="btn btn-outline btn-sm"
|
|
||||||
>View Contract</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recent Activity -->
|
|
||||||
<div class="mt-6">
|
|
||||||
<h3 class="text-xl font-semibold mb-4">Recent Activity</h3>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Activity</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>2024-01-15</td>
|
|
||||||
<td>Resume Book Access</td>
|
|
||||||
<td
|
|
||||||
><span class="badge badge-success"
|
|
||||||
>Completed</span
|
|
||||||
></td
|
|
||||||
>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>2024-01-10</td>
|
|
||||||
<td>Workshop Scheduling</td>
|
|
||||||
<td
|
|
||||||
><span class="badge badge-warning">Pending</span
|
|
||||||
></td
|
|
||||||
>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -4,6 +4,7 @@ import FilePreview from '../universal/FilePreview';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import type { ItemizedExpense } from '../../../schemas/pocketbase';
|
import type { ItemizedExpense } from '../../../schemas/pocketbase';
|
||||||
|
// import ZoomablePreview from '../universal/ZoomablePreview';
|
||||||
|
|
||||||
interface ReceiptFormData {
|
interface ReceiptFormData {
|
||||||
file: File;
|
file: File;
|
||||||
|
@ -66,6 +67,35 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
const [locationAddress, setLocationAddress] = useState<string>('');
|
const [locationAddress, setLocationAddress] = useState<string>('');
|
||||||
const [notes, setNotes] = useState<string>('');
|
const [notes, setNotes] = useState<string>('');
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
|
const [jsonInput, setJsonInput] = useState<string>('');
|
||||||
|
const [showJsonInput, setShowJsonInput] = useState<boolean>(false);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState<number>(1);
|
||||||
|
|
||||||
|
// Sample JSON data for users to copy
|
||||||
|
const sampleJsonData = {
|
||||||
|
itemized_expenses: [
|
||||||
|
{
|
||||||
|
description: "Presentation supplies for IEEE workshop",
|
||||||
|
category: "Supplies",
|
||||||
|
amount: 45.99
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Team lunch during planning meeting",
|
||||||
|
category: "Meals",
|
||||||
|
amount: 82.50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Transportation to conference venue",
|
||||||
|
category: "Travel",
|
||||||
|
amount: 28.75
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tax: 12.65,
|
||||||
|
date: "2024-01-15",
|
||||||
|
location_name: "Office Depot & Local Restaurant",
|
||||||
|
location_address: "1234 Campus Drive, San Diego, CA 92093",
|
||||||
|
notes: "Expenses for January IEEE workshop preparation and team coordination meeting"
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files[0]) {
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
@ -144,6 +174,69 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseJsonData = () => {
|
||||||
|
try {
|
||||||
|
if (!jsonInput.trim()) {
|
||||||
|
toast.error('Please enter JSON data to parse');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(jsonInput);
|
||||||
|
|
||||||
|
// Validate the structure
|
||||||
|
if (!parsed.itemized_expenses || !Array.isArray(parsed.itemized_expenses)) {
|
||||||
|
throw new Error('itemized_expenses must be an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each expense item
|
||||||
|
for (const item of parsed.itemized_expenses) {
|
||||||
|
if (!item.description || !item.category || typeof item.amount !== 'number') {
|
||||||
|
throw new Error('Each expense item must have description, category, and amount');
|
||||||
|
}
|
||||||
|
if (!EXPENSE_CATEGORIES.includes(item.category)) {
|
||||||
|
throw new Error(`Invalid category: ${item.category}. Must be one of: ${EXPENSE_CATEGORIES.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the form fields
|
||||||
|
setItemizedExpenses(parsed.itemized_expenses);
|
||||||
|
if (parsed.tax !== undefined) setTax(Number(parsed.tax) || 0);
|
||||||
|
if (parsed.date) setDate(parsed.date);
|
||||||
|
if (parsed.location_name) setLocationName(parsed.location_name);
|
||||||
|
if (parsed.location_address) setLocationAddress(parsed.location_address);
|
||||||
|
if (parsed.notes) setNotes(parsed.notes);
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
toast.success(`Successfully imported ${parsed.itemized_expenses.length} expense items`);
|
||||||
|
setShowJsonInput(false);
|
||||||
|
setJsonInput('');
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Invalid JSON format';
|
||||||
|
setError(`JSON Parse Error: ${errorMessage}`);
|
||||||
|
toast.error(`Failed to parse JSON: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
toast.success('Sample data copied to clipboard!');
|
||||||
|
}).catch(() => {
|
||||||
|
toast.error('Failed to copy to clipboard');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomIn = () => {
|
||||||
|
setZoomLevel(prev => Math.min(prev + 0.25, 3));
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomOut = () => {
|
||||||
|
setZoomLevel(prev => Math.max(prev - 0.25, 0.5));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetZoom = () => {
|
||||||
|
setZoomLevel(1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
|
@ -155,7 +248,11 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
className="space-y-4 overflow-y-auto max-h-[70vh] pr-4 custom-scrollbar"
|
className="space-y-4 overflow-y-auto max-h-[70vh] pr-8 overflow-x-hidden"
|
||||||
|
style={{
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
scrollbarColor: 'rgba(156, 163, 175, 0.5) transparent'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
|
@ -191,78 +288,183 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Date */}
|
{/* Date and Location in Grid */}
|
||||||
<motion.div variants={itemVariants} className="form-control">
|
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<label className="label">
|
<div className="form-control">
|
||||||
<span className="label-text font-medium">Date</span>
|
<label className="label">
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text font-medium">Date</span>
|
||||||
</label>
|
<span className="label-text-alt text-error">*</span>
|
||||||
<input
|
</label>
|
||||||
type="date"
|
<input
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
type="date"
|
||||||
value={date}
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
onChange={(e) => setDate(e.target.value)}
|
value={date}
|
||||||
required
|
onChange={(e) => setDate(e.target.value)}
|
||||||
/>
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-medium">Tax Amount ($)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
|
value={tax === 0 ? '' : tax}
|
||||||
|
onChange={(e) => setTax(Number(e.target.value))}
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Location Name */}
|
{/* Location Fields */}
|
||||||
<motion.div variants={itemVariants} className="form-control">
|
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<label className="label">
|
<div className="form-control">
|
||||||
<span className="label-text font-medium">Location Name</span>
|
<label className="label">
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text font-medium">Location Name</span>
|
||||||
</label>
|
<span className="label-text-alt text-error">*</span>
|
||||||
<input
|
</label>
|
||||||
type="text"
|
<input
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
type="text"
|
||||||
value={locationName}
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
onChange={(e) => setLocationName(e.target.value)}
|
value={locationName}
|
||||||
required
|
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>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Location Address */}
|
{/* Notes - Reduced height */}
|
||||||
<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">
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text font-medium">Notes</span>
|
<span className="label-text font-medium">Notes</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[100px]"
|
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300"
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
rows={3}
|
rows={2}
|
||||||
|
placeholder="Additional notes..."
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* JSON Import Section */}
|
||||||
|
<motion.div variants={itemVariants} className="space-y-4">
|
||||||
|
<div className="card bg-base-200/30 border border-primary/20 shadow-sm">
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-primary">Quick Import from JSON</h3>
|
||||||
|
<p className="text-sm text-base-content/70">Paste receipt data in JSON format to auto-populate fields</p>
|
||||||
|
</div>
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm gap-2"
|
||||||
|
onClick={() => setShowJsonInput(!showJsonInput)}
|
||||||
|
>
|
||||||
|
<Icon icon={showJsonInput ? "heroicons:chevron-up" : "heroicons:chevron-down"} className="h-4 w-4" />
|
||||||
|
{showJsonInput ? 'Hide' : 'Show'} JSON Import
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showJsonInput && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="space-y-4 mt-4 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Sample Data Section */}
|
||||||
|
<div className="bg-base-100/50 rounded-lg p-4 border border-base-300/50">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h4 className="font-medium text-sm">Sample JSON Format:</h4>
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-xs btn-ghost gap-1"
|
||||||
|
onClick={() => copyToClipboard(JSON.stringify(sampleJsonData, null, 2))}
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:clipboard-document" className="h-3 w-3" />
|
||||||
|
Copy Sample
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs bg-base-200/50 p-3 rounded border overflow-x-auto">
|
||||||
|
<code>{JSON.stringify(sampleJsonData, null, 2)}</code>
|
||||||
|
</pre>
|
||||||
|
<div className="mt-2 text-xs text-base-content/60">
|
||||||
|
<p><strong>Required fields:</strong> itemized_expenses (array)</p>
|
||||||
|
<p><strong>Optional fields:</strong> tax, date, location_name, location_address, notes</p>
|
||||||
|
<p><strong>Valid categories:</strong> {EXPENSE_CATEGORIES.join(', ')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JSON Input Area */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-medium">Paste your JSON data:</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-bordered w-full min-h-[150px] font-mono text-sm"
|
||||||
|
value={jsonInput}
|
||||||
|
onChange={(e) => setJsonInput(e.target.value)}
|
||||||
|
placeholder="Paste your JSON data here..."
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => setJsonInput('')}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</motion.button>
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm gap-2"
|
||||||
|
onClick={parseJsonData}
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:arrow-down-tray" className="h-4 w-4" />
|
||||||
|
Import Data
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Itemized Expenses */}
|
{/* Itemized Expenses */}
|
||||||
<motion.div variants={itemVariants} className="space-y-4">
|
<motion.div variants={itemVariants} className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<label className="text-lg font-medium">Itemized Expenses</label>
|
<label className="text-lg font-medium">Itemized Expenses</label>
|
||||||
<motion.button
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary btn-sm gap-2 hover:shadow-lg transition-all duration-300"
|
|
||||||
onClick={addExpenseItem}
|
|
||||||
>
|
|
||||||
<Icon icon="heroicons:plus" className="h-4 w-4" />
|
|
||||||
Add Item
|
|
||||||
</motion.button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
@ -274,33 +476,48 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
exit={{ opacity: 0, x: 20 }}
|
exit={{ opacity: 0, x: 20 }}
|
||||||
className="card bg-base-200/50 hover:bg-base-200 transition-colors duration-300 backdrop-blur-sm shadow-sm"
|
className="card bg-base-200/50 hover:bg-base-200 transition-colors duration-300 backdrop-blur-sm shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="card-body p-4">
|
<div className="card-body p-3">
|
||||||
<div className="grid gap-4">
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<h4 className="text-sm font-medium">Item #{index + 1}</h4>
|
||||||
|
{itemizedExpenses.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-xs btn-ghost text-error hover:bg-error/10"
|
||||||
|
onClick={() => removeExpenseItem(index)}
|
||||||
|
aria-label="Remove item"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:trash" className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label className="label">
|
<label className="label py-1">
|
||||||
<span className="label-text">Description</span>
|
<span className="label-text text-xs">Description</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input input-bordered"
|
className="input input-bordered input-sm"
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
|
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
|
||||||
|
placeholder="What was purchased?"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label className="label">
|
<label className="label py-1">
|
||||||
<span className="label-text">Category</span>
|
<span className="label-text text-xs">Category</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
className="select select-bordered"
|
className="select select-bordered select-sm w-full"
|
||||||
value={item.category}
|
value={item.category}
|
||||||
onChange={(e) => handleExpenseItemChange(index, 'category', e.target.value)}
|
onChange={(e) => handleExpenseItemChange(index, 'category', e.target.value)}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Select category</option>
|
<option value="">Select...</option>
|
||||||
{EXPENSE_CATEGORIES.map(category => (
|
{EXPENSE_CATEGORIES.map(category => (
|
||||||
<option key={category} value={category}>{category}</option>
|
<option key={category} value={category}>{category}</option>
|
||||||
))}
|
))}
|
||||||
|
@ -308,29 +525,19 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label className="label">
|
<label className="label py-1">
|
||||||
<span className="label-text">Amount ($)</span>
|
<span className="label-text text-xs">Amount ($)</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center space-x-2">
|
<input
|
||||||
<input
|
type="number"
|
||||||
type="number"
|
className="input input-bordered input-sm w-full"
|
||||||
className="input input-bordered"
|
value={item.amount === 0 ? '' : item.amount}
|
||||||
value={item.amount}
|
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))}
|
||||||
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))}
|
min="0"
|
||||||
min="0"
|
step="0.01"
|
||||||
step="0.01"
|
placeholder="0.00"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{itemizedExpenses.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-square btn-sm btn-error"
|
|
||||||
onClick={() => removeExpenseItem(index)}
|
|
||||||
>
|
|
||||||
<Icon icon="heroicons:trash" className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -338,38 +545,41 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Tax */}
|
{/* Add Item Button - Moved to bottom */}
|
||||||
<motion.div variants={itemVariants} className="form-control">
|
<motion.div
|
||||||
<label className="label">
|
initial={{ opacity: 0, y: 10 }}
|
||||||
<span className="label-text font-medium">Tax Amount ($)</span>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
</label>
|
className="flex justify-center pt-2"
|
||||||
<input
|
>
|
||||||
type="number"
|
<motion.button
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
whileHover={{ scale: 1.05 }}
|
||||||
value={tax}
|
whileTap={{ scale: 0.95 }}
|
||||||
onChange={(e) => setTax(Number(e.target.value))}
|
type="button"
|
||||||
min="0"
|
className="btn btn-primary btn-sm gap-2 hover:shadow-lg transition-all duration-300"
|
||||||
step="0.01"
|
onClick={addExpenseItem}
|
||||||
/>
|
>
|
||||||
|
<Icon icon="heroicons:plus" className="h-4 w-4" />
|
||||||
|
Add Item
|
||||||
|
</motion.button>
|
||||||
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Total */}
|
{/* Total */}
|
||||||
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-3 shadow-sm">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<div className="flex justify-between items-center text-base-content/70">
|
<div className="flex justify-between items-center text-sm text-base-content/70">
|
||||||
<span>Subtotal:</span>
|
<span>Subtotal:</span>
|
||||||
<span className="font-mono">${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)}</span>
|
<span className="font-mono">${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-base-content/70">
|
<div className="flex justify-between items-center text-sm text-base-content/70">
|
||||||
<span>Tax:</span>
|
<span>Tax:</span>
|
||||||
<span className="font-mono">${tax.toFixed(2)}</span>
|
<span className="font-mono">${tax.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider my-1"></div>
|
<div className="divider my-1"></div>
|
||||||
<div className="flex justify-between items-center font-medium text-lg">
|
<div className="flex justify-between items-center font-medium">
|
||||||
<span>Total:</span>
|
<span>Total:</span>
|
||||||
<span className="font-mono text-primary">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span>
|
<span className="font-mono text-primary text-lg">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
@ -412,13 +622,60 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||||
className="bg-base-200/50 backdrop-blur-sm rounded-xl p-4 shadow-sm"
|
className="bg-base-200/50 backdrop-blur-sm rounded-xl shadow-sm relative"
|
||||||
>
|
>
|
||||||
<FilePreview
|
{/* Zoom Controls */}
|
||||||
url={previewUrl}
|
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2 bg-base-100/90 backdrop-blur-sm rounded-lg p-2 shadow-lg">
|
||||||
filename={file?.name || ''}
|
<motion.button
|
||||||
isModal={false}
|
whileHover={{ scale: 1.1 }}
|
||||||
/>
|
whileTap={{ scale: 0.9 }}
|
||||||
|
className="btn btn-xs btn-ghost"
|
||||||
|
onClick={zoomIn}
|
||||||
|
disabled={zoomLevel >= 3}
|
||||||
|
title="Zoom In"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:plus" className="h-3 w-3" />
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<div className="text-xs text-center font-mono px-1">
|
||||||
|
{Math.round(zoomLevel * 100)}%
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
className="btn btn-xs btn-ghost"
|
||||||
|
onClick={zoomOut}
|
||||||
|
disabled={zoomLevel <= 0.5}
|
||||||
|
title="Zoom Out"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:minus" className="h-3 w-3" />
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
className="btn btn-xs btn-ghost"
|
||||||
|
onClick={resetZoom}
|
||||||
|
disabled={zoomLevel === 1}
|
||||||
|
title="Reset Zoom"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:arrows-pointing-out" className="h-3 w-3" />
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview with Zoom */}
|
||||||
|
<div
|
||||||
|
className="overflow-auto h-full rounded-xl"
|
||||||
|
style={{
|
||||||
|
transform: `scale(${zoomLevel})`,
|
||||||
|
transformOrigin: 'top left',
|
||||||
|
height: zoomLevel > 1 ? `${100 / zoomLevel}%` : '100%',
|
||||||
|
width: zoomLevel > 1 ? `${100 / zoomLevel}%` : '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilePreview url={previewUrl} filename={file?.name || ''} />
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
@ -439,4 +696,4 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -1,13 +1,14 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
|
import { EmailClient } from '../../../scripts/email/EmailClient';
|
||||||
import ReceiptForm from './ReceiptForm';
|
import ReceiptForm from './ReceiptForm';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import FilePreview from '../universal/FilePreview';
|
import FilePreview from '../universal/FilePreview';
|
||||||
import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase';
|
import type { ItemizedExpense, Reimbursement } from '../../../schemas/pocketbase';
|
||||||
|
|
||||||
interface ReceiptFormData {
|
interface ReceiptFormData {
|
||||||
file: File;
|
file: File;
|
||||||
|
@ -277,11 +278,34 @@ export default function ReimbursementForm() {
|
||||||
formData.append('receipts', JSON.stringify(request.receipts));
|
formData.append('receipts', JSON.stringify(request.receipts));
|
||||||
formData.append('department', request.department);
|
formData.append('department', request.department);
|
||||||
|
|
||||||
await pb.collection('reimbursement').create(formData);
|
// Create the reimbursement record
|
||||||
|
const newReimbursement = await pb.collection('reimbursement').create(formData);
|
||||||
|
|
||||||
// Sync the reimbursements collection to update IndexedDB
|
// Sync the reimbursements collection to update IndexedDB
|
||||||
const dataSync = DataSyncService.getInstance();
|
const dataSync = DataSyncService.getInstance();
|
||||||
await dataSync.syncCollection(Collections.REIMBURSEMENTS);
|
|
||||||
|
// Force sync with specific filter to ensure the new record is fetched
|
||||||
|
await dataSync.syncCollection(
|
||||||
|
Collections.REIMBURSEMENTS,
|
||||||
|
`submitted_by="${userId}"`,
|
||||||
|
'-created',
|
||||||
|
'audit_notes'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the new record is in IndexedDB
|
||||||
|
const syncedData = await dataSync.getData(
|
||||||
|
Collections.REIMBURSEMENTS,
|
||||||
|
true, // Force sync again to be sure
|
||||||
|
`id="${newReimbursement.id}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (syncedData.length === 0) {
|
||||||
|
console.warn('New reimbursement not found in IndexedDB after sync, forcing another sync');
|
||||||
|
// Try one more time with a slight delay
|
||||||
|
setTimeout(async () => {
|
||||||
|
await dataSync.syncCollection(Collections.REIMBURSEMENTS);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
setRequest({
|
setRequest({
|
||||||
|
@ -308,6 +332,14 @@ export default function ReimbursementForm() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send email notification
|
||||||
|
try {
|
||||||
|
await EmailClient.notifySubmission(newReimbursement.id);
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Failed to send submission email notification:', emailError);
|
||||||
|
// Don't fail the entire operation if email fails
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting reimbursement request:', error);
|
console.error('Error submitting reimbursement request:', error);
|
||||||
toast.error('Failed to submit reimbursement request. Please try again.');
|
toast.error('Failed to submit reimbursement request. Please try again.');
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import { Get } from '../../../scripts/pocketbase/Get';
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
|
@ -114,6 +114,27 @@ export default function ReimbursementList() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// console.log('Component mounted');
|
// console.log('Component mounted');
|
||||||
fetchReimbursements();
|
fetchReimbursements();
|
||||||
|
|
||||||
|
// Set up an interval to refresh the reimbursements list periodically
|
||||||
|
const refreshInterval = setInterval(() => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
fetchReimbursements();
|
||||||
|
}
|
||||||
|
}, 30000); // Refresh every 30 seconds when tab is visible
|
||||||
|
|
||||||
|
// Listen for visibility changes to refresh when user returns to the tab
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
fetchReimbursements();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(refreshInterval);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Add effect to monitor requests state
|
// Add effect to monitor requests state
|
||||||
|
@ -156,7 +177,7 @@ export default function ReimbursementList() {
|
||||||
// Use DataSyncService to get data from IndexedDB with forced sync
|
// Use DataSyncService to get data from IndexedDB with forced sync
|
||||||
const dataSync = DataSyncService.getInstance();
|
const dataSync = DataSyncService.getInstance();
|
||||||
|
|
||||||
// Sync reimbursements collection
|
// Sync reimbursements collection with force sync
|
||||||
await dataSync.syncCollection(
|
await dataSync.syncCollection(
|
||||||
Collections.REIMBURSEMENTS,
|
Collections.REIMBURSEMENTS,
|
||||||
`submitted_by="${userId}"`,
|
`submitted_by="${userId}"`,
|
||||||
|
@ -164,10 +185,10 @@ export default function ReimbursementList() {
|
||||||
'audit_notes'
|
'audit_notes'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get reimbursements from IndexedDB
|
// Get reimbursements from IndexedDB with forced sync to ensure latest data
|
||||||
const reimbursementRecords = await dataSync.getData<ReimbursementRequest>(
|
const reimbursementRecords = await dataSync.getData<ReimbursementRequest>(
|
||||||
Collections.REIMBURSEMENTS,
|
Collections.REIMBURSEMENTS,
|
||||||
false, // Don't force sync again
|
true, // Force sync to ensure we have the latest data
|
||||||
`submitted_by="${userId}"`,
|
`submitted_by="${userId}"`,
|
||||||
'-created'
|
'-created'
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
import { Update } from '../../../scripts/pocketbase/Update';
|
import { Update } from '../../../scripts/pocketbase/Update';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
import { FileManager } from '../../../scripts/pocketbase/FileManager';
|
import { FileManager } from '../../../scripts/pocketbase/FileManager';
|
||||||
|
import { EmailClient } from '../../../scripts/email/EmailClient';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import type { Receipt as SchemaReceipt, User, Reimbursement } from '../../../schemas/pocketbase';
|
import type { Receipt as SchemaReceipt, User, Reimbursement } from '../../../schemas/pocketbase';
|
||||||
|
@ -32,6 +33,10 @@ interface FilterOptions {
|
||||||
dateRange: 'all' | 'week' | 'month' | 'year';
|
dateRange: 'all' | 'week' | 'month' | 'year';
|
||||||
sortBy: 'date_of_purchase' | 'total_amount' | 'status';
|
sortBy: 'date_of_purchase' | 'total_amount' | 'status';
|
||||||
sortOrder: 'asc' | 'desc';
|
sortOrder: 'asc' | 'desc';
|
||||||
|
hidePaid: boolean; // Auto-hide paid reimbursements
|
||||||
|
hideRejected: boolean; // Auto-hide rejected reimbursements
|
||||||
|
compactView: boolean; // Toggle for compact list view
|
||||||
|
search: string; // Search query
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ItemizedExpense {
|
interface ItemizedExpense {
|
||||||
|
@ -53,7 +58,11 @@ export default function ReimbursementManagementPortal() {
|
||||||
department: [],
|
department: [],
|
||||||
dateRange: 'all',
|
dateRange: 'all',
|
||||||
sortBy: 'date_of_purchase',
|
sortBy: 'date_of_purchase',
|
||||||
sortOrder: 'desc'
|
sortOrder: 'desc',
|
||||||
|
hidePaid: true,
|
||||||
|
hideRejected: true,
|
||||||
|
compactView: false,
|
||||||
|
search: ''
|
||||||
});
|
});
|
||||||
const [auditNote, setAuditNote] = useState('');
|
const [auditNote, setAuditNote] = useState('');
|
||||||
const [loadingStatus, setLoadingStatus] = useState(false);
|
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||||
|
@ -110,6 +119,21 @@ export default function ReimbursementManagementPortal() {
|
||||||
filter = `(${statusFilter})`;
|
filter = `(${statusFilter})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When searching, don't auto-hide paid/rejected unless explicitly filtered
|
||||||
|
const isSearching = filters.search.trim().length > 0;
|
||||||
|
|
||||||
|
// Auto-hide paid reimbursements if the option is enabled and not searching
|
||||||
|
if (filters.hidePaid && !isSearching) {
|
||||||
|
const hidePaidFilter = 'status != "paid"';
|
||||||
|
filter = filter ? `${filter} && ${hidePaidFilter}` : hidePaidFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-hide rejected reimbursements if the option is enabled and not searching
|
||||||
|
if (filters.hideRejected && !isSearching) {
|
||||||
|
const hideRejectedFilter = 'status != "rejected"';
|
||||||
|
filter = filter ? `${filter} && ${hideRejectedFilter}` : hideRejectedFilter;
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.department.length > 0) {
|
if (filters.department.length > 0) {
|
||||||
const departmentFilter = filters.department.map(d => `department = "${d}"`).join(' || ');
|
const departmentFilter = filters.department.map(d => `department = "${d}"`).join(' || ');
|
||||||
filter = filter ? `${filter} && (${departmentFilter})` : `(${departmentFilter})`;
|
filter = filter ? `${filter} && (${departmentFilter})` : `(${departmentFilter})`;
|
||||||
|
@ -160,11 +184,10 @@ export default function ReimbursementManagementPortal() {
|
||||||
submitter: userMap[record.submitted_by]
|
submitter: userMap[record.submitted_by]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setReimbursements(enrichedRecords);
|
|
||||||
|
|
||||||
// Load associated receipts
|
// Load associated receipts
|
||||||
const receiptIds = enrichedRecords.flatMap(r => r.receipts || []);
|
const receiptIds = enrichedRecords.flatMap(r => r.receipts || []);
|
||||||
|
|
||||||
|
let receiptMap: Record<string, ExtendedReceipt> = {};
|
||||||
if (receiptIds.length > 0) {
|
if (receiptIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
const receiptRecords = await Promise.all(
|
const receiptRecords = await Promise.all(
|
||||||
|
@ -200,7 +223,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
|
|
||||||
const validReceipts = receiptRecords.filter((r): r is ExtendedReceipt => r !== null);
|
const validReceipts = receiptRecords.filter((r): r is ExtendedReceipt => r !== null);
|
||||||
|
|
||||||
const receiptMap = Object.fromEntries(
|
receiptMap = Object.fromEntries(
|
||||||
validReceipts.map(receipt => [receipt.id, receipt])
|
validReceipts.map(receipt => [receipt.id, receipt])
|
||||||
);
|
);
|
||||||
setReceipts(receiptMap);
|
setReceipts(receiptMap);
|
||||||
|
@ -217,6 +240,52 @@ export default function ReimbursementManagementPortal() {
|
||||||
// console.log('No receipt IDs found in reimbursements');
|
// console.log('No receipt IDs found in reimbursements');
|
||||||
setReceipts({});
|
setReceipts({});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply client-side search filtering
|
||||||
|
let filteredRecords = enrichedRecords;
|
||||||
|
if (isSearching) {
|
||||||
|
const searchTerm = filters.search.toLowerCase().trim();
|
||||||
|
|
||||||
|
filteredRecords = enrichedRecords.filter(record => {
|
||||||
|
// Search in title
|
||||||
|
if (record.title.toLowerCase().includes(searchTerm)) return true;
|
||||||
|
|
||||||
|
// Search in submitter name
|
||||||
|
if (record.submitter?.name?.toLowerCase().includes(searchTerm)) return true;
|
||||||
|
|
||||||
|
// Search in date (multiple formats)
|
||||||
|
const date = new Date(record.date_of_purchase);
|
||||||
|
const dateFormats = [
|
||||||
|
date.toLocaleDateString(), // Default locale format
|
||||||
|
date.toLocaleDateString('en-US'), // MM/DD/YYYY
|
||||||
|
date.toISOString().split('T')[0], // YYYY-MM-DD
|
||||||
|
date.toDateString(), // "Mon Jan 01 2024"
|
||||||
|
`${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`, // M/D/YYYY
|
||||||
|
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` // YYYY-MM-DD
|
||||||
|
];
|
||||||
|
if (dateFormats.some(format => format.toLowerCase().includes(searchTerm))) return true;
|
||||||
|
|
||||||
|
// Search in receipt location names
|
||||||
|
const reimbursementReceipts = record.receipts?.map(id => receiptMap[id]).filter(Boolean) || [];
|
||||||
|
if (reimbursementReceipts.some(receipt =>
|
||||||
|
receipt.location_name?.toLowerCase().includes(searchTerm) ||
|
||||||
|
receipt.location_address?.toLowerCase().includes(searchTerm)
|
||||||
|
)) return true;
|
||||||
|
|
||||||
|
// Search in department
|
||||||
|
if (record.department.toLowerCase().includes(searchTerm)) return true;
|
||||||
|
|
||||||
|
// Search in status
|
||||||
|
if (record.status.toLowerCase().replace('_', ' ').includes(searchTerm)) return true;
|
||||||
|
|
||||||
|
// Search in additional info
|
||||||
|
if (record.additional_info?.toLowerCase().includes(searchTerm)) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setReimbursements(filteredRecords);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading reimbursements:', error);
|
console.error('Error loading reimbursements:', error);
|
||||||
toast.error('Failed to load reimbursements. Please try again later.');
|
toast.error('Failed to load reimbursements. Please try again later.');
|
||||||
|
@ -366,7 +435,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update the updateStatus function
|
// Update the updateStatus function
|
||||||
const updateStatus = async (id: string, status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'in_progress' | 'paid') => {
|
const updateStatus = async (id: string, status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'in_progress' | 'paid', showToast: boolean = true) => {
|
||||||
try {
|
try {
|
||||||
setLoadingStatus(true);
|
setLoadingStatus(true);
|
||||||
const update = Update.getInstance();
|
const update = Update.getInstance();
|
||||||
|
@ -375,15 +444,28 @@ export default function ReimbursementManagementPortal() {
|
||||||
|
|
||||||
if (!userId) throw new Error('User not authenticated');
|
if (!userId) throw new Error('User not authenticated');
|
||||||
|
|
||||||
|
// Store previous status for email notification
|
||||||
|
const previousStatus = selectedReimbursement?.status || 'unknown';
|
||||||
|
|
||||||
await update.updateFields('reimbursement', id, { status });
|
await update.updateFields('reimbursement', id, { status });
|
||||||
|
|
||||||
// Add audit log for status change
|
// Add audit log for status change
|
||||||
await addAuditLog(id, 'status_change', {
|
await addAuditLog(id, 'status_change', {
|
||||||
from: selectedReimbursement?.status,
|
from: previousStatus,
|
||||||
to: status
|
to: status
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(`Reimbursement ${status} successfully`);
|
// Send email notification
|
||||||
|
try {
|
||||||
|
await EmailClient.notifyStatusChange(id, status, previousStatus, userId);
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Failed to send email notification:', emailError);
|
||||||
|
// Don't fail the entire operation if email fails
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showToast) {
|
||||||
|
toast.success(`Reimbursement ${status} successfully`);
|
||||||
|
}
|
||||||
await refreshAuditData(id);
|
await refreshAuditData(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating status:', error);
|
console.error('Error updating status:', error);
|
||||||
|
@ -483,8 +565,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setSelectedReceipt(receipt);
|
// Don't show the receipt modal when auditing
|
||||||
setShowReceiptModal(true);
|
|
||||||
toast.success('Receipt audited successfully');
|
toast.success('Receipt audited successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error auditing receipt:', error);
|
console.error('Error auditing receipt:', error);
|
||||||
|
@ -582,6 +663,21 @@ export default function ReimbursementManagementPortal() {
|
||||||
is_private: isPrivateNote
|
is_private: isPrivateNote
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send email notification for public comments
|
||||||
|
if (!isPrivateNote) {
|
||||||
|
try {
|
||||||
|
await EmailClient.notifyComment(
|
||||||
|
selectedReimbursement.id,
|
||||||
|
auditNote.trim(),
|
||||||
|
userId,
|
||||||
|
isPrivateNote
|
||||||
|
);
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Failed to send comment email notification:', emailError);
|
||||||
|
// Don't fail the entire operation if email fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toast.success('Audit note saved successfully');
|
toast.success('Audit note saved successfully');
|
||||||
setAuditNote('');
|
setAuditNote('');
|
||||||
setIsPrivateNote(true);
|
setIsPrivateNote(true);
|
||||||
|
@ -613,8 +709,8 @@ export default function ReimbursementManagementPortal() {
|
||||||
try {
|
try {
|
||||||
setLoadingStatus(true);
|
setLoadingStatus(true);
|
||||||
|
|
||||||
// First update the status
|
// First update the status (passing false to suppress the toast message)
|
||||||
await updateStatus(rejectingId, 'rejected');
|
await updateStatus(rejectingId, 'rejected', false);
|
||||||
|
|
||||||
// Then add the rejection reason as a public note
|
// Then add the rejection reason as a public note
|
||||||
const auth = Authentication.getInstance();
|
const auth = Authentication.getInstance();
|
||||||
|
@ -693,12 +789,59 @@ export default function ReimbursementManagementPortal() {
|
||||||
<h2 className="text-lg sm:text-xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
<h2 className="text-lg sm:text-xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||||
Reimbursement Requests
|
Reimbursement Requests
|
||||||
</h2>
|
</h2>
|
||||||
<span className="badge badge-primary badge-md font-medium">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{reimbursements.length} Total
|
<span className="badge badge-primary badge-md font-medium">
|
||||||
</span>
|
{reimbursements.length} Total
|
||||||
|
</span>
|
||||||
|
{filters.hidePaid && (
|
||||||
|
<span className="badge badge-ghost badge-sm font-medium" title="Paid reimbursements are automatically hidden">
|
||||||
|
<Icon icon="heroicons:eye-slash" className="h-3 w-3 mr-1" />
|
||||||
|
Paid Hidden
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{filters.hideRejected && (
|
||||||
|
<span className="badge badge-ghost badge-sm font-medium" title="Rejected reimbursements are automatically hidden">
|
||||||
|
<Icon icon="heroicons:eye-slash" className="h-3 w-3 mr-1" />
|
||||||
|
Rejected Hidden
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="form-control sm:col-span-2">
|
||||||
|
<div className="join h-9 relative">
|
||||||
|
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
||||||
|
<Icon icon="heroicons:magnifying-glass" className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`input input-bordered input-sm w-full focus:outline-none h-full join-item rounded-l-none ${filters.search ? 'pr-16' : 'pr-8'}`}
|
||||||
|
placeholder="Search by title, user, date, receipt location..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
||||||
|
/>
|
||||||
|
{filters.search && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm absolute right-2 top-0 h-full px-2"
|
||||||
|
onClick={() => setFilters(prev => ({ ...prev, search: '' }))}
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:x-mark" className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{filters.search && (
|
||||||
|
<div className="label py-1">
|
||||||
|
<span className="label-text-alt text-info">
|
||||||
|
<Icon icon="heroicons:information-circle" className="h-3 w-3 inline mr-1" />
|
||||||
|
Search includes all reimbursements (including paid/rejected)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<div className="join h-9 relative">
|
<div className="join h-9 relative">
|
||||||
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
||||||
|
@ -754,6 +897,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Department Filter */}
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<div className="join h-9 relative">
|
<div className="join h-9 relative">
|
||||||
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
||||||
|
@ -806,6 +950,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Date Range Filter */}
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<div className="join h-9">
|
<div className="join h-9">
|
||||||
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
||||||
|
@ -824,7 +969,8 @@ export default function ReimbursementManagementPortal() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-control md:col-span-2">
|
{/* Sort Controls */}
|
||||||
|
<div className="form-control">
|
||||||
<div className="join h-9">
|
<div className="join h-9">
|
||||||
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
||||||
<Icon icon="heroicons:arrows-up-down" className="h-4 w-4" />
|
<Icon icon="heroicons:arrows-up-down" className="h-4 w-4" />
|
||||||
|
@ -850,6 +996,54 @@ export default function ReimbursementManagementPortal() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Additional Filter Options */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-4 border-t border-base-300 mt-4">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label cursor-pointer justify-start gap-3 p-0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-primary checkbox-sm"
|
||||||
|
checked={filters.hidePaid}
|
||||||
|
onChange={(e) => setFilters({ ...filters, hidePaid: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon icon="heroicons:eye-slash" className="h-4 w-4 text-base-content/70" />
|
||||||
|
<span className="label-text font-medium">Auto-hide paid requests</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label cursor-pointer justify-start gap-3 p-0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-primary checkbox-sm"
|
||||||
|
checked={filters.hideRejected}
|
||||||
|
onChange={(e) => setFilters({ ...filters, hideRejected: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon icon="heroicons:eye-slash" className="h-4 w-4 text-base-content/70" />
|
||||||
|
<span className="label-text font-medium">Auto-hide rejected requests</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label cursor-pointer justify-start gap-3 p-0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-primary checkbox-sm"
|
||||||
|
checked={filters.compactView}
|
||||||
|
onChange={(e) => setFilters({ ...filters, compactView: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon icon="heroicons:list-bullet" className="h-4 w-4 text-base-content/70" />
|
||||||
|
<span className="label-text font-medium">Compact view</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
@ -873,7 +1067,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<div className="space-y-4">
|
<div className={`${filters.compactView ? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2' : 'space-y-4'}`}>
|
||||||
{reimbursements.map((reimbursement, index) => (
|
{reimbursements.map((reimbursement, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={reimbursement.id}
|
key={reimbursement.id}
|
||||||
|
@ -884,47 +1078,76 @@ export default function ReimbursementManagementPortal() {
|
||||||
${selectedReimbursement?.id === reimbursement.id ? 'ring-2 ring-primary shadow-lg scale-[1.02]' : 'hover:scale-[1.01] hover:shadow-md'}`}
|
${selectedReimbursement?.id === reimbursement.id ? 'ring-2 ring-primary shadow-lg scale-[1.02]' : 'hover:scale-[1.01] hover:shadow-md'}`}
|
||||||
onClick={() => setSelectedReimbursement(reimbursement)}
|
onClick={() => setSelectedReimbursement(reimbursement)}
|
||||||
>
|
>
|
||||||
<div className="card-body p-5">
|
{filters.compactView ? (
|
||||||
<div className="flex justify-between items-start gap-4">
|
// Compact Grid View
|
||||||
<div className="space-y-2 flex-1 min-w-0">
|
<div className="card-body p-3">
|
||||||
<h3 className="font-bold text-lg group-hover:text-primary transition-colors truncate">
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold text-sm group-hover:text-primary transition-colors line-clamp-2 leading-tight">
|
||||||
{reimbursement.title}
|
{reimbursement.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-3 text-sm">
|
<div className="flex items-center justify-between text-xs text-base-content/70">
|
||||||
<div className="flex items-center gap-1.5 text-base-content/70">
|
<span>{new Date(reimbursement.date_of_purchase).toLocaleDateString()}</span>
|
||||||
<Icon icon="heroicons:calendar" className="h-4 w-4 flex-shrink-0" />
|
<span className="font-mono font-bold text-primary text-sm">
|
||||||
<span>{new Date(reimbursement.date_of_purchase).toLocaleDateString()}</span>
|
${reimbursement.total_amount.toFixed(2)}
|
||||||
</div>
|
</span>
|
||||||
<div className="flex items-center gap-1.5 text-base-content/70">
|
</div>
|
||||||
<Icon icon="heroicons:building-office" className="h-4 w-4 flex-shrink-0" />
|
<div className="flex justify-center">
|
||||||
<span className="truncate">{reimbursement.department}</span>
|
<span className={`badge badge-sm ${reimbursement.status === 'approved' ? 'badge-success' :
|
||||||
</div>
|
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>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
</div>
|
||||||
<span className="font-mono font-bold text-lg text-primary whitespace-nowrap">
|
) : (
|
||||||
${reimbursement.total_amount.toFixed(2)}
|
// Regular View
|
||||||
</span>
|
<div className="card-body p-5">
|
||||||
<span className={`badge ${reimbursement.status === 'approved' ? 'badge-success' :
|
<div className="flex justify-between items-start gap-4">
|
||||||
reimbursement.status === 'rejected' ? 'badge-error' :
|
<div className="space-y-2 flex-1 min-w-0">
|
||||||
reimbursement.status === 'under_review' ? 'badge-info' :
|
<h3 className="font-bold text-lg group-hover:text-primary transition-colors truncate">
|
||||||
reimbursement.status === 'in_progress' ? 'badge-warning' :
|
{reimbursement.title}
|
||||||
reimbursement.status === 'paid' ? 'badge-success' :
|
</h3>
|
||||||
'badge-ghost'
|
<div className="flex flex-wrap gap-3 text-sm">
|
||||||
} gap-1.5 px-3 py-2.5 capitalize font-medium`}>
|
<div className="flex items-center gap-1.5 text-base-content/70">
|
||||||
<Icon icon={
|
<Icon icon="heroicons:calendar" className="h-4 w-4 flex-shrink-0" />
|
||||||
reimbursement.status === 'approved' ? 'heroicons:check-circle' :
|
<span>{new Date(reimbursement.date_of_purchase).toLocaleDateString()}</span>
|
||||||
reimbursement.status === 'rejected' ? 'heroicons:x-circle' :
|
</div>
|
||||||
reimbursement.status === 'under_review' ? 'heroicons:eye' :
|
<div className="flex items-center gap-1.5 text-base-content/70">
|
||||||
reimbursement.status === 'in_progress' ? 'heroicons:currency-dollar' :
|
<Icon icon="heroicons:building-office" className="h-4 w-4 flex-shrink-0" />
|
||||||
reimbursement.status === 'paid' ? 'heroicons:banknotes' :
|
<span className="truncate">{reimbursement.department}</span>
|
||||||
'heroicons:clock'
|
</div>
|
||||||
} className="h-4 w-4 flex-shrink-0" />
|
</div>
|
||||||
{reimbursement.status.replace('_', ' ')}
|
</div>
|
||||||
</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1709,4 +1932,4 @@ export default function ReimbursementManagementPortal() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
50
src/components/dashboard/universal/Button.tsx
Normal file
50
src/components/dashboard/universal/Button.tsx
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'danger';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button: React.FC<ButtonProps> = ({
|
||||||
|
children,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
fullWidth = false,
|
||||||
|
className = '',
|
||||||
|
disabled = false,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
// Base classes
|
||||||
|
const baseClasses = 'font-medium rounded-md focus:outline-none transition-colors';
|
||||||
|
|
||||||
|
// Size classes
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'px-3 py-1.5 text-xs',
|
||||||
|
md: 'px-4 py-2 text-sm',
|
||||||
|
lg: 'px-6 py-2.5 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Variant classes
|
||||||
|
const variantClasses = {
|
||||||
|
primary: `bg-blue-600 text-white hover:bg-blue-700 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`,
|
||||||
|
secondary: `bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`,
|
||||||
|
danger: `bg-red-600 text-white hover:bg-red-700 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Width classes
|
||||||
|
const widthClasses = fullWidth ? 'w-full' : '';
|
||||||
|
|
||||||
|
// Combine all classes
|
||||||
|
const buttonClasses = `${baseClasses} ${sizeClasses[size]} ${variantClasses[variant]} ${widthClasses} ${className}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={buttonClasses}
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
|
@ -14,6 +14,8 @@ interface ImageWithFallbackProps {
|
||||||
const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) => {
|
const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) => {
|
||||||
const [imgSrc, setImgSrc] = useState<string>(url);
|
const [imgSrc, setImgSrc] = useState<string>(url);
|
||||||
const [isObjectUrl, setIsObjectUrl] = useState<boolean>(false);
|
const [isObjectUrl, setIsObjectUrl] = useState<boolean>(false);
|
||||||
|
const [errorCount, setErrorCount] = useState<number>(0);
|
||||||
|
const maxRetries = 2;
|
||||||
|
|
||||||
// Clean up object URL when component unmounts
|
// Clean up object URL when component unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -24,13 +26,51 @@ const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) =
|
||||||
};
|
};
|
||||||
}, [imgSrc, url, isObjectUrl]);
|
}, [imgSrc, url, isObjectUrl]);
|
||||||
|
|
||||||
|
// Reset when URL changes
|
||||||
|
useEffect(() => {
|
||||||
|
setImgSrc(url);
|
||||||
|
setIsObjectUrl(false);
|
||||||
|
setErrorCount(0);
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
// Special handling for blob URLs
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBlobUrl = async () => {
|
||||||
|
if (url.startsWith('blob:') && !isObjectUrl) {
|
||||||
|
try {
|
||||||
|
// For blob URLs, we don't need to fetch again, just set directly
|
||||||
|
setImgSrc(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error with blob URL:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleBlobUrl();
|
||||||
|
}, [url, isObjectUrl]);
|
||||||
|
|
||||||
const handleError = async () => {
|
const handleError = async () => {
|
||||||
console.error('Image failed to load:', url);
|
// Prevent infinite retry loops
|
||||||
|
if (errorCount >= maxRetries) {
|
||||||
|
console.error(`Image failed to load after ${maxRetries} attempts:`, url);
|
||||||
|
onError('Failed to load image after multiple attempts. The file may be corrupted or unsupported.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorCount(prev => prev + 1);
|
||||||
|
console.error(`Image failed to load (attempt ${errorCount + 1}):`, url);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Skip fetch for blob URLs that already failed
|
||||||
|
if (url.startsWith('blob:')) {
|
||||||
|
throw new Error('Blob URL failed to load directly');
|
||||||
|
}
|
||||||
|
|
||||||
// Try to fetch the image as a blob and create an object URL
|
// Try to fetch the image as a blob and create an object URL
|
||||||
// console.log('Trying to fetch image as blob:', url);
|
const response = await fetch(url, {
|
||||||
const response = await fetch(url, { mode: 'cors' });
|
mode: 'cors',
|
||||||
|
cache: 'no-cache' // Avoid caching issues
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
@ -38,27 +78,24 @@ const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) =
|
||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
// console.log('Created object URL:', objectUrl);
|
|
||||||
|
|
||||||
// Update the image source with the object URL
|
// Update the image source with the object URL
|
||||||
setImgSrc(objectUrl);
|
setImgSrc(objectUrl);
|
||||||
setIsObjectUrl(true);
|
setIsObjectUrl(true);
|
||||||
} catch (fetchError) {
|
} catch (fetchError) {
|
||||||
console.error('Error fetching image as blob:', fetchError);
|
console.error('Error fetching image as blob:', fetchError);
|
||||||
onError('Failed to load image. This might be due to permission issues or the file may not exist.');
|
|
||||||
|
|
||||||
// Log additional details
|
// Only show error to user on final retry
|
||||||
// console.log('Image URL that failed:', url);
|
if (errorCount >= maxRetries - 1) {
|
||||||
// console.log('Current auth status:',
|
onError('Failed to load image. This might be due to permission issues or the file may not exist.');
|
||||||
// Authentication.getInstance().isAuthenticated() ? 'Authenticated' : 'Not authenticated'
|
}
|
||||||
// );
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={imgSrc}
|
src={imgSrc}
|
||||||
alt={filename}
|
alt={filename || 'Image preview'}
|
||||||
className="max-w-full h-auto rounded-lg"
|
className="max-w-full h-auto rounded-lg"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
|
@ -167,6 +204,22 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
setState(prev => ({ ...prev, loading: true, error: null }));
|
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Check cache first
|
||||||
|
const cacheKey = `${state.url}_${state.filename}`;
|
||||||
|
const cachedData = contentCache.get(cacheKey);
|
||||||
|
|
||||||
|
if (cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) {
|
||||||
|
// Use cached data
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: cachedData.content,
|
||||||
|
fileType: cachedData.fileType,
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
loadingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Special handling for PDFs
|
// Special handling for PDFs
|
||||||
if (state.url.endsWith('.pdf')) {
|
if (state.url.endsWith('.pdf')) {
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
|
@ -175,12 +228,377 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
fileType: 'application/pdf',
|
fileType: 'application/pdf',
|
||||||
loading: false
|
loading: false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
contentCache.set(cacheKey, {
|
||||||
|
content: 'pdf',
|
||||||
|
fileType: 'application/pdf',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rest of your existing loadContent logic
|
// Handle image files
|
||||||
// ... existing content loading code ...
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'];
|
||||||
|
if (imageExtensions.some(ext => state.url.toLowerCase().endsWith(ext) ||
|
||||||
|
(state.filename && state.filename.toLowerCase().endsWith(ext)))) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: 'image',
|
||||||
|
fileType: 'image/' + (state.url.split('.').pop() || 'jpeg'),
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
contentCache.set(cacheKey, {
|
||||||
|
content: 'image',
|
||||||
|
fileType: 'image/' + (state.url.split('.').pop() || 'jpeg'),
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
loadingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle video files
|
||||||
|
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi'];
|
||||||
|
if (videoExtensions.some(ext => state.url.toLowerCase().endsWith(ext) ||
|
||||||
|
(state.filename && state.filename.toLowerCase().endsWith(ext)))) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: 'video',
|
||||||
|
fileType: 'video/' + (state.url.split('.').pop() || 'mp4'),
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
contentCache.set(cacheKey, {
|
||||||
|
content: 'video',
|
||||||
|
fileType: 'video/' + (state.url.split('.').pop() || 'mp4'),
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
loadingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other file types, try to fetch the content
|
||||||
|
// Handle blob URLs (for local file previews)
|
||||||
|
if (state.url.startsWith('blob:')) {
|
||||||
|
try {
|
||||||
|
// Determine file type from filename if available
|
||||||
|
let fileType = '';
|
||||||
|
if (state.filename) {
|
||||||
|
const extension = state.filename.split('.').pop()?.toLowerCase();
|
||||||
|
if (extension) {
|
||||||
|
switch (extension) {
|
||||||
|
case 'jpg':
|
||||||
|
case 'jpeg':
|
||||||
|
case 'png':
|
||||||
|
case 'gif':
|
||||||
|
case 'webp':
|
||||||
|
case 'bmp':
|
||||||
|
case 'svg':
|
||||||
|
fileType = `image/${extension === 'jpg' ? 'jpeg' : extension}`;
|
||||||
|
break;
|
||||||
|
case 'mp4':
|
||||||
|
case 'webm':
|
||||||
|
case 'ogg':
|
||||||
|
case 'mov':
|
||||||
|
fileType = `video/${extension}`;
|
||||||
|
break;
|
||||||
|
case 'pdf':
|
||||||
|
fileType = 'application/pdf';
|
||||||
|
break;
|
||||||
|
case 'doc':
|
||||||
|
fileType = 'application/msword';
|
||||||
|
break;
|
||||||
|
case 'docx':
|
||||||
|
fileType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||||
|
break;
|
||||||
|
case 'xls':
|
||||||
|
fileType = 'application/vnd.ms-excel';
|
||||||
|
break;
|
||||||
|
case 'xlsx':
|
||||||
|
fileType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||||
|
break;
|
||||||
|
case 'ppt':
|
||||||
|
fileType = 'application/vnd.ms-powerpoint';
|
||||||
|
break;
|
||||||
|
case 'pptx':
|
||||||
|
fileType = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
|
||||||
|
break;
|
||||||
|
case 'txt':
|
||||||
|
case 'md':
|
||||||
|
case 'js':
|
||||||
|
case 'jsx':
|
||||||
|
case 'ts':
|
||||||
|
case 'tsx':
|
||||||
|
case 'html':
|
||||||
|
case 'css':
|
||||||
|
case 'json':
|
||||||
|
case 'yml':
|
||||||
|
case 'yaml':
|
||||||
|
case 'csv':
|
||||||
|
fileType = 'text/plain';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
fileType = 'application/octet-stream';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to fetch the blob
|
||||||
|
const response = await fetch(state.url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// If we couldn't determine file type from filename, use the blob type
|
||||||
|
if (!fileType && blob.type) {
|
||||||
|
fileType = blob.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different file types
|
||||||
|
if (fileType.startsWith('image/') ||
|
||||||
|
(state.filename && /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(state.filename))) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: 'image',
|
||||||
|
fileType: fileType || 'image/jpeg',
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
contentCache.set(cacheKey, {
|
||||||
|
content: 'image',
|
||||||
|
fileType: fileType || 'image/jpeg',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else if (fileType.startsWith('video/') ||
|
||||||
|
(state.filename && /\.(mp4|webm|ogg|mov|avi)$/i.test(state.filename))) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: 'video',
|
||||||
|
fileType: fileType || 'video/mp4',
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
contentCache.set(cacheKey, {
|
||||||
|
content: 'video',
|
||||||
|
fileType: fileType || 'video/mp4',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else if (fileType === 'application/pdf' ||
|
||||||
|
(state.filename && /\.pdf$/i.test(state.filename))) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: 'pdf',
|
||||||
|
fileType: 'application/pdf',
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
contentCache.set(cacheKey, {
|
||||||
|
content: 'pdf',
|
||||||
|
fileType: 'application/pdf',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
fileType === 'application/msword' ||
|
||||||
|
fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||||
|
fileType === 'application/vnd.ms-excel' ||
|
||||||
|
fileType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
||||||
|
fileType === 'application/vnd.ms-powerpoint' ||
|
||||||
|
fileType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
|
||||||
|
(state.filename && /\.(doc|docx|xls|xlsx|ppt|pptx)$/i.test(state.filename))
|
||||||
|
) {
|
||||||
|
// Handle Office documents with a document icon and download option
|
||||||
|
const extension = state.filename?.split('.').pop()?.toLowerCase() || '';
|
||||||
|
let documentType = 'document';
|
||||||
|
|
||||||
|
if (['xls', 'xlsx'].includes(extension)) {
|
||||||
|
documentType = 'spreadsheet';
|
||||||
|
} else if (['ppt', 'pptx'].includes(extension)) {
|
||||||
|
documentType = 'presentation';
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: `document-${documentType}`,
|
||||||
|
fileType: fileType || `application/${documentType}`,
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
contentCache.set(cacheKey, {
|
||||||
|
content: `document-${documentType}`,
|
||||||
|
fileType: fileType || `application/${documentType}`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For text files, read the content
|
||||||
|
try {
|
||||||
|
const text = await blob.text();
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: text,
|
||||||
|
fileType: fileType || 'text/plain',
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
contentCache.set(cacheKey, {
|
||||||
|
content: text,
|
||||||
|
fileType: fileType || 'text/plain',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} catch (textError) {
|
||||||
|
console.error('Error reading blob as text:', textError);
|
||||||
|
throw new Error('Failed to read file content');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingRef.current = false;
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing blob URL:', error);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
error: 'Failed to load file preview. Please try again or proceed with upload.',
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
loadingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For remote files
|
||||||
|
const response = await fetch(state.url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
|
||||||
|
if (contentType.startsWith('image/')) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: 'image',
|
||||||
|
fileType: contentType,
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
contentCache.set(cacheKey, {
|
||||||
|
content: 'image',
|
||||||
|
fileType: contentType,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else if (contentType.startsWith('video/')) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: 'video',
|
||||||
|
fileType: contentType,
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
contentCache.set(cacheKey, {
|
||||||
|
content: 'video',
|
||||||
|
fileType: contentType,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else if (contentType === 'application/pdf') {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: 'pdf',
|
||||||
|
fileType: contentType,
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
contentCache.set(cacheKey, {
|
||||||
|
content: 'pdf',
|
||||||
|
fileType: contentType,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
contentType === 'application/msword' ||
|
||||||
|
contentType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||||
|
(state.filename && /\.(doc|docx)$/i.test(state.filename))
|
||||||
|
) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: 'document-document',
|
||||||
|
fileType: contentType || 'application/document',
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
contentCache.set(cacheKey, {
|
||||||
|
content: 'document-document',
|
||||||
|
fileType: contentType || 'application/document',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
contentType === 'application/vnd.ms-excel' ||
|
||||||
|
contentType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
||||||
|
(state.filename && /\.(xls|xlsx)$/i.test(state.filename))
|
||||||
|
) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: 'document-spreadsheet',
|
||||||
|
fileType: contentType || 'application/spreadsheet',
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
contentCache.set(cacheKey, {
|
||||||
|
content: 'document-spreadsheet',
|
||||||
|
fileType: contentType || 'application/spreadsheet',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
contentType === 'application/vnd.ms-powerpoint' ||
|
||||||
|
contentType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
|
||||||
|
(state.filename && /\.(ppt|pptx)$/i.test(state.filename))
|
||||||
|
) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: 'document-presentation',
|
||||||
|
fileType: contentType || 'application/presentation',
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
contentCache.set(cacheKey, {
|
||||||
|
content: 'document-presentation',
|
||||||
|
fileType: contentType || 'application/presentation',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For text files, read the content
|
||||||
|
const text = await response.text();
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
content: text,
|
||||||
|
fileType: contentType,
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
contentCache.set(cacheKey, {
|
||||||
|
content: text,
|
||||||
|
fileType: contentType,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading content:', err);
|
console.error('Error loading content:', err);
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
|
@ -193,8 +611,20 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
}, [state.url]);
|
}, [state.url]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.url || (!state.isVisible && isModal)) return;
|
if (!state.url) return;
|
||||||
loadContent();
|
|
||||||
|
// For modal, only load when visible
|
||||||
|
if (isModal && !state.isVisible) return;
|
||||||
|
|
||||||
|
// Reset loading state when URL changes
|
||||||
|
loadingRef.current = false;
|
||||||
|
|
||||||
|
// Small timeout to ensure state updates are processed
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
loadContent();
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
}, [state.url, state.isVisible, isModal, loadContent]);
|
}, [state.url, state.isVisible, isModal, loadContent]);
|
||||||
|
|
||||||
// Intersection observer effect
|
// Intersection observer effect
|
||||||
|
@ -364,7 +794,14 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
// Update the Try Again button handler
|
// Update the Try Again button handler
|
||||||
const handleTryAgain = useCallback(() => {
|
const handleTryAgain = useCallback(() => {
|
||||||
loadingRef.current = false; // Reset loading ref
|
loadingRef.current = false; // Reset loading ref
|
||||||
loadContent();
|
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
|
// If URL is empty, show a message
|
||||||
|
@ -399,7 +836,8 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="preview-content overflow-auto border border-base-300 rounded-lg bg-base-200/50 relative">
|
<div className="preview-content overflow-auto border border-base-300 rounded-lg bg-base-200/50 relative">
|
||||||
{!state.loading && !state.error && state.content === null && (
|
{/* Initial loading state - show immediately when URL is provided but content hasn't loaded yet */}
|
||||||
|
{state.url && !state.loading && !state.error && state.content === null && (
|
||||||
<div className="flex justify-center items-center p-8">
|
<div className="flex justify-center items-center p-8">
|
||||||
<span className="loading loading-spinner loading-lg"></span>
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -448,21 +886,38 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
|
|
||||||
{!state.loading && !state.error && state.content === 'video' && (
|
{!state.loading && !state.error && state.content === 'video' && (
|
||||||
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
|
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
|
||||||
<video
|
<div className="w-full max-w-2xl">
|
||||||
controls
|
<video
|
||||||
className="max-w-full h-auto rounded-lg"
|
controls
|
||||||
preload="metadata"
|
className="max-w-full h-auto rounded-lg"
|
||||||
onError={(e) => {
|
preload="metadata"
|
||||||
console.error('Video failed to load:', e);
|
src={state.url}
|
||||||
setState(prev => ({
|
onError={(e) => {
|
||||||
...prev,
|
console.error('Video failed to load:', e);
|
||||||
error: 'Failed to load video. This might be due to permission issues or the file may not exist.'
|
|
||||||
}));
|
// For blob URLs, try a different approach
|
||||||
}}
|
if (state.url.startsWith('blob:')) {
|
||||||
>
|
const videoElement = e.target as HTMLVideoElement;
|
||||||
<source src={state.url} type={state.fileType || 'video/mp4'} />
|
|
||||||
Your browser does not support the video tag.
|
// Try to set the src directly
|
||||||
</video>
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -522,6 +977,41 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!state.loading && !state.error && state.content && state.content.startsWith('document-') && (
|
||||||
|
<div className="flex flex-col items-center justify-center bg-base-200 p-8 rounded-b-lg">
|
||||||
|
<div className="bg-primary/10 p-6 rounded-full mb-6">
|
||||||
|
<Icon
|
||||||
|
icon={
|
||||||
|
state.content === 'document-spreadsheet'
|
||||||
|
? "mdi:file-excel"
|
||||||
|
: state.content === 'document-presentation'
|
||||||
|
? "mdi:file-powerpoint"
|
||||||
|
: "mdi:file-word"
|
||||||
|
}
|
||||||
|
className="h-16 w-16 text-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold mb-2">{state.filename}</h3>
|
||||||
|
<p className="text-base-content/70 mb-6 text-center max-w-md">
|
||||||
|
This document cannot be previewed in the browser. Please download it to view its contents.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={state.url}
|
||||||
|
download={state.filename}
|
||||||
|
className="btn btn-primary btn-lg gap-2"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:download" className="h-5 w-5" />
|
||||||
|
Download {
|
||||||
|
state.content === 'document-spreadsheet'
|
||||||
|
? 'Spreadsheet'
|
||||||
|
: state.content === 'document-presentation'
|
||||||
|
? 'Presentation'
|
||||||
|
: 'Document'
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!state.loading && !state.error && state.content && !['image', 'video', 'pdf'].includes(state.content) && (
|
{!state.loading && !state.error && state.content && !['image', 'video', 'pdf'].includes(state.content) && (
|
||||||
<div className="overflow-x-auto max-h-[600px] bg-base-200">
|
<div className="overflow-x-auto max-h-[600px] bg-base-200">
|
||||||
<div className={`p-1 ${state.filename.toLowerCase().endsWith('.csv') ? 'p-4' : ''}`}>
|
<div className={`p-1 ${state.filename.toLowerCase().endsWith('.csv') ? 'p-4' : ''}`}>
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||||
import type { User } from "../../../schemas/pocketbase/schema";
|
|
||||||
import FirstTimeLoginPopup from "./FirstTimeLoginPopup";
|
import FirstTimeLoginPopup from "./FirstTimeLoginPopup";
|
||||||
|
|
||||||
interface FirstTimeLoginManagerProps {
|
interface FirstTimeLoginManagerProps {
|
||||||
|
|
|
@ -84,20 +84,15 @@ export default function ThemeToggle() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="dropdown dropdown-end">
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
className={`inline-flex items-center justify-center rounded-full w-8 h-8 text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground ${isLoading ? 'opacity-70' : ''}`}
|
className={`btn btn-circle btn-sm ${isLoading ? 'loading' : ''}`}
|
||||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}
|
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}
|
||||||
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme (Light mode is experimental)`}
|
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme (Light mode is experimental)`}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{!isLoading && (
|
||||||
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
theme === 'light' ? (
|
theme === 'light' ? (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
@ -109,9 +104,9 @@ export default function ThemeToggle() {
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute right-0 z-10 mt-2 w-52 origin-top-right rounded-md bg-card shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none hidden group-hover:block">
|
<div className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52 mt-2 text-xs">
|
||||||
<div className="p-3 text-xs">
|
<div className="p-2">
|
||||||
<p className="font-bold text-amber-600 dark:text-amber-400 mb-1">Warning:</p>
|
<p className="font-bold text-warning mb-1">Warning:</p>
|
||||||
<p>Light mode is experimental and not fully supported yet. Some UI elements may not display correctly.</p>
|
<p>Light mode is experimental and not fully supported yet. Some UI elements may not display correctly.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
85
src/components/dashboard/universal/Toast.tsx
Normal file
85
src/components/dashboard/universal/Toast.tsx
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface ToastProps {
|
||||||
|
message: string;
|
||||||
|
type?: 'success' | 'error' | 'info' | 'warning';
|
||||||
|
duration?: number;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Toast: React.FC<ToastProps> = ({
|
||||||
|
message,
|
||||||
|
type = 'info',
|
||||||
|
duration = 3000,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
if (onClose) onClose();
|
||||||
|
}, duration);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [duration, onClose]);
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
// Type-based styling
|
||||||
|
const typeStyles = {
|
||||||
|
success: 'bg-green-100 border-green-500 text-green-700 dark:bg-green-800 dark:text-green-100',
|
||||||
|
error: 'bg-red-100 border-red-500 text-red-700 dark:bg-red-800 dark:text-red-100',
|
||||||
|
warning: 'bg-yellow-100 border-yellow-500 text-yellow-700 dark:bg-yellow-800 dark:text-yellow-100',
|
||||||
|
info: 'bg-blue-100 border-blue-500 text-blue-700 dark:bg-blue-800 dark:text-blue-100',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Icons based on type
|
||||||
|
const icons = {
|
||||||
|
success: (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zm-1 7a1 1 0 100 2h.01a1 1 0 100-2H10z" clipRule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-4 right-4 z-50 animate-fade-in">
|
||||||
|
<div className={`flex items-center p-4 mb-4 border-l-4 rounded-md shadow-md ${typeStyles[type]}`} role="alert">
|
||||||
|
<div className="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 mr-3">
|
||||||
|
{icons[type]}
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 text-sm font-medium">{message}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-auto -mx-1.5 -my-1.5 rounded-lg p-1.5 inline-flex h-8 w-8 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||||
|
onClick={() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
if (onClose) onClose();
|
||||||
|
}}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
156
src/components/dashboard/universal/ZoomablePreview.tsx
Normal file
156
src/components/dashboard/universal/ZoomablePreview.tsx
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import FilePreview from './FilePreview';
|
||||||
|
|
||||||
|
interface ZoomablePreviewProps {
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ZoomablePreview({ url, filename }: ZoomablePreviewProps) {
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const zoomLevels = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
||||||
|
const currentZoomIndex = zoomLevels.findIndex(level => Math.abs(level - zoom) < 0.01);
|
||||||
|
|
||||||
|
const handleZoomIn = useCallback(() => {
|
||||||
|
const nextIndex = Math.min(currentZoomIndex + 1, zoomLevels.length - 1);
|
||||||
|
setZoom(zoomLevels[nextIndex]);
|
||||||
|
}, [currentZoomIndex]);
|
||||||
|
|
||||||
|
const handleZoomOut = useCallback(() => {
|
||||||
|
const prevIndex = Math.max(currentZoomIndex - 1, 0);
|
||||||
|
setZoom(zoomLevels[prevIndex]);
|
||||||
|
}, [currentZoomIndex]);
|
||||||
|
|
||||||
|
const handleZoomReset = useCallback(() => {
|
||||||
|
setZoom(1);
|
||||||
|
setPosition({ x: 0, y: 0 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (zoom > 1) {
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragStart({
|
||||||
|
x: e.clientX - position.x,
|
||||||
|
y: e.clientY - position.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [zoom, position]);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (isDragging && zoom > 1) {
|
||||||
|
setPosition({
|
||||||
|
x: e.clientX - dragStart.x,
|
||||||
|
y: e.clientY - dragStart.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isDragging, dragStart, zoom]);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? -1 : 1;
|
||||||
|
const newZoomIndex = Math.max(0, Math.min(zoomLevels.length - 1, currentZoomIndex + delta));
|
||||||
|
setZoom(zoomLevels[newZoomIndex]);
|
||||||
|
|
||||||
|
// Reset position when zooming out to 100% or less
|
||||||
|
if (zoomLevels[newZoomIndex] <= 1) {
|
||||||
|
setPosition({ x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
}, [currentZoomIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full">
|
||||||
|
{/* Zoom Controls */}
|
||||||
|
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2 bg-base-100/90 backdrop-blur-sm rounded-lg p-2 shadow-lg">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
className="btn btn-xs btn-ghost"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
disabled={currentZoomIndex >= zoomLevels.length - 1}
|
||||||
|
title="Zoom In"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:plus" className="h-3 w-3" />
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<div className="text-xs text-center font-mono px-1">
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
className="btn btn-xs btn-ghost"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
disabled={currentZoomIndex <= 0}
|
||||||
|
title="Zoom Out"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:minus" className="h-3 w-3" />
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
className="btn btn-xs btn-ghost"
|
||||||
|
onClick={handleZoomReset}
|
||||||
|
disabled={zoom === 1 && position.x === 0 && position.y === 0}
|
||||||
|
title="Reset Zoom"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:arrows-pointing-out" className="h-3 w-3" />
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom Indicator */}
|
||||||
|
{zoom !== 1 && (
|
||||||
|
<div className="absolute top-4 left-4 z-10 bg-primary/90 backdrop-blur-sm text-primary-content text-xs px-2 py-1 rounded">
|
||||||
|
{zoom > 1 ? 'Click and drag to pan' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview Container */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative h-full overflow-hidden rounded-lg cursor-move"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
style={{
|
||||||
|
cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full transition-transform duration-100"
|
||||||
|
style={{
|
||||||
|
transform: `scale(${zoom}) translate(${position.x / zoom}px, ${position.y / zoom}px)`,
|
||||||
|
transformOrigin: 'center center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-4 h-full">
|
||||||
|
<FilePreview
|
||||||
|
url={url}
|
||||||
|
filename={filename}
|
||||||
|
isModal={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Hint */}
|
||||||
|
<div className="absolute bottom-4 left-4 z-10 text-xs text-base-content/50 bg-base-100/80 backdrop-blur-sm px-2 py-1 rounded">
|
||||||
|
Scroll to zoom • Click and drag to pan
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -17,7 +17,7 @@ import { LiaDotCircle } from "react-icons/lia";
|
||||||
To stay up to date, join discord server
|
To stay up to date, join discord server
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="https://www.facebook.com/ieeeucsd" target="_blank" className="mr-[20%] flex flex-col items-center">
|
<Link href="https://discord.gg/ubr2suwc2f" target="_blank" className="mr-[20%] flex flex-col items-center">
|
||||||
<div class="border-[0.15vw] rounded-full shadow-glow hover:scale-110 duration-300 p-[1vw] text-[2.2vw] text-ieee-black/80 border-ieee-blue-100 bg-gradient-radial from-white via-white to-white/40">
|
<div class="border-[0.15vw] rounded-full shadow-glow hover:scale-110 duration-300 p-[1vw] text-[2.2vw] text-ieee-black/80 border-ieee-blue-100 bg-gradient-radial from-white via-white to-white/40">
|
||||||
<FaDiscord />
|
<FaDiscord />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -36,6 +36,13 @@ sections:
|
||||||
component: "Officer_EventManagement"
|
component: "Officer_EventManagement"
|
||||||
class: "text-info hover:text-info-focus"
|
class: "text-info hover:text-info-focus"
|
||||||
|
|
||||||
|
officerEmailManagement:
|
||||||
|
title: "IEEE Email Management"
|
||||||
|
icon: "heroicons:envelope"
|
||||||
|
role: "general"
|
||||||
|
component: "Officer_EmailManagement"
|
||||||
|
class: "text-info hover:text-info-focus"
|
||||||
|
|
||||||
reimbursementManagement:
|
reimbursementManagement:
|
||||||
title: "Reimbursement Management"
|
title: "Reimbursement Management"
|
||||||
icon: "heroicons:credit-card"
|
icon: "heroicons:credit-card"
|
||||||
|
@ -50,6 +57,13 @@ sections:
|
||||||
component: "Officer_EventRequestManagement"
|
component: "Officer_EventRequestManagement"
|
||||||
class: "text-info hover:text-info-focus"
|
class: "text-info hover:text-info-focus"
|
||||||
|
|
||||||
|
officerManagement:
|
||||||
|
title: "Officer Management"
|
||||||
|
icon: "heroicons:user-group"
|
||||||
|
role: "executive"
|
||||||
|
component: "OfficerManagement"
|
||||||
|
class: "text-info hover:text-info-focus"
|
||||||
|
|
||||||
eventRequestForm:
|
eventRequestForm:
|
||||||
title: "Event Request Form"
|
title: "Event Request Form"
|
||||||
icon: "heroicons:document-text"
|
icon: "heroicons:document-text"
|
||||||
|
@ -58,19 +72,19 @@ sections:
|
||||||
class: "text-info hover:text-info-focus"
|
class: "text-info hover:text-info-focus"
|
||||||
|
|
||||||
# Sponsor Menu
|
# Sponsor Menu
|
||||||
sponsorDashboard:
|
|
||||||
title: "Sponsor Dashboard"
|
|
||||||
icon: "heroicons:briefcase"
|
|
||||||
role: "sponsor"
|
|
||||||
component: "SponsorDashboard"
|
|
||||||
class: "text-warning hover:text-warning-focus"
|
|
||||||
|
|
||||||
sponsorAnalytics:
|
sponsorAnalytics:
|
||||||
title: "Analytics"
|
title: "Event Analytics"
|
||||||
icon: "heroicons:chart-bar"
|
icon: "heroicons:chart-bar"
|
||||||
role: "sponsor"
|
role: "sponsor"
|
||||||
component: "SponsorAnalytics"
|
component: "SponsorAnalyticsSection"
|
||||||
class: "text-warning hover:text-warning-focus"
|
class: "text-primary hover:text-primary-focus"
|
||||||
|
|
||||||
|
resumeDatabase:
|
||||||
|
title: "Resume Database"
|
||||||
|
icon: "heroicons:document-text"
|
||||||
|
role: "sponsor"
|
||||||
|
component: "ResumeDatabase"
|
||||||
|
class: "text-secondary hover:text-secondary-focus"
|
||||||
|
|
||||||
# Administrator Menu
|
# Administrator Menu
|
||||||
adminDashboard:
|
adminDashboard:
|
||||||
|
@ -103,12 +117,13 @@ categories:
|
||||||
|
|
||||||
officer:
|
officer:
|
||||||
title: "Officer Menu"
|
title: "Officer Menu"
|
||||||
sections: ["eventManagement", "eventRequestForm"]
|
sections: ["eventManagement", "officerEmailManagement", "eventRequestForm"]
|
||||||
role: "general"
|
role: "general"
|
||||||
|
|
||||||
executive:
|
executive:
|
||||||
title: "Executive Menu"
|
title: "Executive Menu"
|
||||||
sections: ["reimbursementManagement", "eventRequestManagement"]
|
sections:
|
||||||
|
["reimbursementManagement", "eventRequestManagement", "officerManagement"]
|
||||||
role: "executive"
|
role: "executive"
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
|
@ -118,10 +133,10 @@ categories:
|
||||||
|
|
||||||
sponsor:
|
sponsor:
|
||||||
title: "Sponsor Portal"
|
title: "Sponsor Portal"
|
||||||
sections: ["sponsorDashboard", "sponsorAnalytics"]
|
sections: ["sponsorAnalytics", "resumeDatabase"]
|
||||||
role: "sponsor"
|
role: "sponsor"
|
||||||
|
|
||||||
account:
|
account:
|
||||||
title: "Account"
|
title: "Account"
|
||||||
sections: ["settings", "logout"]
|
sections: ["settings"]
|
||||||
role: "none"
|
role: "none"
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
{
|
{
|
||||||
"title": "Quarterly Project",
|
"title": "Quarterly Project",
|
||||||
"text": "QP is a quarter-long project competition. Students collaborate in teams of up to 6 to build a product aligned with a quarterly theme.",
|
"text": "QP is a quarter-long project competition. Students collaborate in teams of up to 6 to build a product aligned with a quarterly theme.",
|
||||||
"link": "/quarterly",
|
"link": "/projects/quarterly",
|
||||||
"number": "01",
|
"number": "01",
|
||||||
"delay": "100"
|
"delay": "100"
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,42 +2,41 @@
|
||||||
import Navbar from "../components/core/Navbar.astro";
|
import Navbar from "../components/core/Navbar.astro";
|
||||||
import Footer from "../components/core/Footer.astro";
|
import Footer from "../components/core/Footer.astro";
|
||||||
import InView from "../components/core/InView.astro";
|
import InView from "../components/core/InView.astro";
|
||||||
import { initTheme } from "../scripts/database/initTheme";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" class="w-full h-full m-0 bg-ieee-black">
|
<html lang="en" data-theme="dark" class="w-full h-full m-0 bg-ieee-black">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>IEEEUCSD</title>
|
<title>IEEEUCSD</title>
|
||||||
<script
|
<script
|
||||||
src="https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js"
|
src="https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js"
|
||||||
></script>
|
></script>
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
// Set a default theme until IndexedDB loads
|
// Set default theme to dark if not already set
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
if (!localStorage.getItem("theme")) {
|
||||||
</script>
|
localStorage.setItem("theme", "dark");
|
||||||
</head>
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
<InView />
|
} else {
|
||||||
<body class="w-full h-full m-0 bg-ieee-black">
|
// Apply saved theme
|
||||||
<script>
|
const savedTheme = localStorage.getItem("theme");
|
||||||
// Initialize theme from IndexedDB
|
document.documentElement.setAttribute("data-theme", savedTheme);
|
||||||
import { initTheme } from "../scripts/database/initTheme";
|
}
|
||||||
initTheme().catch((err) =>
|
</script>
|
||||||
console.error("Error initializing theme:", err)
|
</head>
|
||||||
);
|
<InView />
|
||||||
</script>
|
<body class="w-full h-full m-0 bg-ieee-black">
|
||||||
<div class="text-white min-h-screen">
|
<div class="text-white min-h-screen">
|
||||||
<header class="sticky top-0 w-full z-[999]">
|
<header class="sticky top-0 w-full z-[999]">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
</header>
|
</header>
|
||||||
<main class="w-[95%] mx-auto">
|
<main class="w-[95%] mx-auto">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -292,7 +292,7 @@ async function sendCredentialsEmail(
|
||||||
|
|
||||||
Please change your password after your first login.
|
Please change your password after your first login.
|
||||||
|
|
||||||
If you have any questions, please contact webmaster@ieeeucsd.org.
|
If you have any questions, please contact webmaster@ieeeatucsd.org.
|
||||||
|
|
||||||
Best regards,
|
Best regards,
|
||||||
IEEE UCSD Web Team
|
IEEE UCSD Web Team
|
||||||
|
@ -311,7 +311,7 @@ async function sendWebmasterNotification(
|
||||||
) {
|
) {
|
||||||
// In a real implementation, you would use an email service
|
// In a real implementation, you would use an email service
|
||||||
console.log(`
|
console.log(`
|
||||||
To: webmaster@ieeeucsd.org
|
To: webmaster@ieeeatucsd.org
|
||||||
Subject: New IEEE Email Account Created
|
Subject: New IEEE Email Account Created
|
||||||
|
|
||||||
A new IEEE email account has been created:
|
A new IEEE email account has been created:
|
||||||
|
|
120
src/pages/api/email/send-event-request-email.ts
Normal file
120
src/pages/api/email/send-event-request-email.ts
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { initializeEmailServices, authenticatePocketBase } from '../../../scripts/email/EmailHelpers';
|
||||||
|
import {
|
||||||
|
sendEventRequestSubmissionEmail,
|
||||||
|
sendEventRequestStatusChangeEmail,
|
||||||
|
sendPRCompletedEmail,
|
||||||
|
sendDesignPRNotificationEmail
|
||||||
|
} from '../../../scripts/email/EventRequestEmailFunctions';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
console.log('📨 Event request email API called');
|
||||||
|
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
eventRequestId,
|
||||||
|
previousStatus,
|
||||||
|
newStatus,
|
||||||
|
changedByUserId,
|
||||||
|
declinedReason,
|
||||||
|
additionalContext,
|
||||||
|
authData
|
||||||
|
} = await request.json();
|
||||||
|
|
||||||
|
console.log('📋 Request data:', {
|
||||||
|
type,
|
||||||
|
eventRequestId,
|
||||||
|
hasAuthData: !!authData,
|
||||||
|
authDataHasToken: !!(authData?.token),
|
||||||
|
authDataHasModel: !!(authData?.model),
|
||||||
|
newStatus,
|
||||||
|
previousStatus
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!type || !eventRequestId) {
|
||||||
|
console.error('❌ Missing required parameters');
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing required parameters: type and eventRequestId' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
const { pb, resend, fromEmail, replyToEmail } = await initializeEmailServices();
|
||||||
|
|
||||||
|
// Authenticate with PocketBase if auth data is provided
|
||||||
|
authenticatePocketBase(pb, authData);
|
||||||
|
|
||||||
|
let success = false;
|
||||||
|
|
||||||
|
console.log(`🎯 Processing event request email type: ${type}`);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'event_request_submission':
|
||||||
|
success = await sendEventRequestSubmissionEmail(pb, resend, fromEmail, replyToEmail, {
|
||||||
|
eventRequestId
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'event_request_status_change':
|
||||||
|
if (!newStatus) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing newStatus for event request status change notification' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
success = await sendEventRequestStatusChangeEmail(pb, resend, fromEmail, replyToEmail, {
|
||||||
|
eventRequestId,
|
||||||
|
newStatus,
|
||||||
|
previousStatus,
|
||||||
|
changedByUserId,
|
||||||
|
declinedReason: declinedReason || additionalContext?.declinedReason
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'pr_completed':
|
||||||
|
success = await sendPRCompletedEmail(pb, resend, fromEmail, replyToEmail, {
|
||||||
|
eventRequestId
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'design_pr_notification':
|
||||||
|
success = await sendDesignPRNotificationEmail(pb, resend, fromEmail, replyToEmail, {
|
||||||
|
eventRequestId,
|
||||||
|
action: additionalContext?.action || 'unknown'
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error('❌ Unknown event request notification type:', type);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Unknown event request notification type: ${type}` }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📊 Event request email operation result: ${success ? 'SUCCESS' : 'FAILED'}`);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success,
|
||||||
|
message: success ? 'Event request email notification sent successfully' : 'Failed to send event request email notification'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: success ? 200 : 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in event request email notification API:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
155
src/pages/api/email/send-officer-notification.ts
Normal file
155
src/pages/api/email/send-officer-notification.ts
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { OfficerEmailNotifications } from '../../../scripts/email/OfficerEmailNotifications';
|
||||||
|
import type { OfficerRoleChangeEmailData } from '../../../scripts/email/OfficerEmailNotifications';
|
||||||
|
import { initializeEmailServices, authenticatePocketBase } from '../../../scripts/email/EmailHelpers';
|
||||||
|
import { Collections } from '../../../schemas/pocketbase';
|
||||||
|
import type { User, Officer } from '../../../schemas/pocketbase/schema';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
console.log('📨 Officer notification email API called');
|
||||||
|
|
||||||
|
const requestData = await request.json();
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
officerId,
|
||||||
|
additionalContext,
|
||||||
|
authData
|
||||||
|
} = requestData;
|
||||||
|
|
||||||
|
console.log('📋 Request data:', {
|
||||||
|
type,
|
||||||
|
officerId,
|
||||||
|
hasAdditionalContext: !!additionalContext,
|
||||||
|
hasAuthData: !!authData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (type !== 'officer_role_change') {
|
||||||
|
console.error('❌ Invalid notification type for officer endpoint:', type);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Invalid notification type: ${type}` }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!officerId) {
|
||||||
|
console.error('❌ Missing required parameter: officerId');
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing required parameter: officerId' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize services - this creates a fresh PocketBase instance for server-side use
|
||||||
|
const { pb } = await initializeEmailServices();
|
||||||
|
|
||||||
|
// Authenticate with PocketBase if auth data is provided
|
||||||
|
authenticatePocketBase(pb, authData);
|
||||||
|
|
||||||
|
const emailService = OfficerEmailNotifications.getInstance();
|
||||||
|
|
||||||
|
// Get the officer record with user data
|
||||||
|
console.log('🔍 Fetching officer data...');
|
||||||
|
const officer = await pb.collection(Collections.OFFICERS).getOne(officerId, {
|
||||||
|
expand: 'user'
|
||||||
|
}) as Officer & { expand?: { user: User } };
|
||||||
|
|
||||||
|
if (!officer) {
|
||||||
|
console.error('❌ Officer not found:', officerId);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Officer not found' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the user data from the expanded relation
|
||||||
|
const user = officer.expand?.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.error('❌ User data not found for officer:', officerId);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'User data not found for officer' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract additional context data
|
||||||
|
const {
|
||||||
|
previousRole,
|
||||||
|
previousType,
|
||||||
|
newRole,
|
||||||
|
newType,
|
||||||
|
changedByUserId,
|
||||||
|
isNewOfficer
|
||||||
|
} = additionalContext || {};
|
||||||
|
|
||||||
|
// Get the name of the person who made the change
|
||||||
|
let changedByName = '';
|
||||||
|
if (changedByUserId) {
|
||||||
|
try {
|
||||||
|
const changedByUser = await pb.collection(Collections.USERS).getOne(changedByUserId) as User;
|
||||||
|
changedByName = changedByUser?.name || 'Unknown User';
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not fetch changed by user name:', error);
|
||||||
|
changedByName = 'Unknown User';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare email data
|
||||||
|
const emailData: OfficerRoleChangeEmailData = {
|
||||||
|
user,
|
||||||
|
officer,
|
||||||
|
previousRole,
|
||||||
|
previousType,
|
||||||
|
newRole: newRole || officer.role,
|
||||||
|
newType: newType || officer.type,
|
||||||
|
changedBy: changedByName,
|
||||||
|
isNewOfficer: isNewOfficer || false
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📧 Sending officer role change notification...');
|
||||||
|
console.log('📧 Email data:', {
|
||||||
|
userName: user.name,
|
||||||
|
userEmail: user.email,
|
||||||
|
officerRole: emailData.newRole,
|
||||||
|
officerType: emailData.newType,
|
||||||
|
previousRole: emailData.previousRole,
|
||||||
|
previousType: emailData.previousType,
|
||||||
|
changedBy: emailData.changedBy,
|
||||||
|
isNewOfficer: emailData.isNewOfficer
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = await emailService.sendRoleChangeNotification(emailData);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
console.log('✅ Officer role change notification sent successfully');
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: 'Officer role change notification sent successfully'
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error('❌ Failed to send officer role change notification');
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to send officer role change notification'
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in officer notification API:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
910
src/pages/api/email/send-reimbursement-email.ts
Normal file
910
src/pages/api/email/send-reimbursement-email.ts
Normal file
|
@ -0,0 +1,910 @@
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { initializeEmailServices, authenticatePocketBase, getStatusColor, getStatusText, getNextStepsText } from '../../../scripts/email/EmailHelpers';
|
||||||
|
|
||||||
|
// Add function to generate status image URL (now SVG-based)
|
||||||
|
function getStatusImageUrl(status: string, baseUrl: string = ''): string {
|
||||||
|
return `${baseUrl}/api/generate-status-image?status=${status}&width=500&height=150`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
console.log('📨 Reimbursement email API called');
|
||||||
|
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
reimbursementId,
|
||||||
|
previousStatus,
|
||||||
|
newStatus,
|
||||||
|
changedByUserId,
|
||||||
|
comment,
|
||||||
|
commentByUserId,
|
||||||
|
isPrivate,
|
||||||
|
additionalContext,
|
||||||
|
authData,
|
||||||
|
useImageProgress = true // New option to use image instead of HTML progress (default: true for better email compatibility)
|
||||||
|
} = await request.json();
|
||||||
|
|
||||||
|
console.log('📋 Request data:', {
|
||||||
|
type,
|
||||||
|
reimbursementId,
|
||||||
|
hasAuthData: !!authData,
|
||||||
|
authDataHasToken: !!(authData?.token),
|
||||||
|
authDataHasModel: !!(authData?.model),
|
||||||
|
commentLength: comment?.length || 0,
|
||||||
|
commentByUserId,
|
||||||
|
isPrivate
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!type || !reimbursementId) {
|
||||||
|
console.error('❌ Missing required parameters');
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing required parameters: type and reimbursementId' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
const { pb, resend, fromEmail, replyToEmail } = await initializeEmailServices();
|
||||||
|
|
||||||
|
// Authenticate with PocketBase if auth data is provided (skip for test emails)
|
||||||
|
if (type !== 'test') {
|
||||||
|
authenticatePocketBase(pb, authData);
|
||||||
|
}
|
||||||
|
|
||||||
|
let success = false;
|
||||||
|
|
||||||
|
console.log(`🎯 Processing reimbursement email type: ${type}`);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'status_change':
|
||||||
|
if (!newStatus) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing newStatus for status_change notification' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
success = await sendStatusChangeEmail(pb, resend, fromEmail, replyToEmail, {
|
||||||
|
reimbursementId,
|
||||||
|
newStatus,
|
||||||
|
previousStatus,
|
||||||
|
changedByUserId,
|
||||||
|
additionalContext,
|
||||||
|
useImageProgress
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'comment':
|
||||||
|
if (!comment || !commentByUserId) {
|
||||||
|
console.error('❌ Missing comment or commentByUserId for comment notification');
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing comment or commentByUserId for comment notification' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
success = await sendCommentEmail(pb, resend, fromEmail, replyToEmail, {
|
||||||
|
reimbursementId,
|
||||||
|
comment,
|
||||||
|
commentByUserId,
|
||||||
|
isPrivate: isPrivate || false
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'submission':
|
||||||
|
success = await sendSubmissionEmail(pb, resend, fromEmail, replyToEmail, {
|
||||||
|
reimbursementId
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'test':
|
||||||
|
const { email } = additionalContext || {};
|
||||||
|
if (!email) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing email for test notification' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
success = await sendTestEmail(resend, fromEmail, replyToEmail, email);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error('❌ Unknown reimbursement notification type:', type);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Unknown reimbursement notification type: ${type}` }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📊 Reimbursement email operation result: ${success ? 'SUCCESS' : 'FAILED'}`);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success,
|
||||||
|
message: success ? 'Reimbursement email notification sent successfully' : 'Failed to send reimbursement email notification'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: success ? 200 : 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in reimbursement email notification API:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions for reimbursement email types
|
||||||
|
async function sendStatusChangeEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log('📧 Starting reimbursement status change email process...');
|
||||||
|
console.log('Environment check:', {
|
||||||
|
hasResendKey: !!import.meta.env.RESEND_API_KEY,
|
||||||
|
fromEmail,
|
||||||
|
replyToEmail,
|
||||||
|
pocketbaseUrl: import.meta.env.POCKETBASE_URL
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if this is a test scenario
|
||||||
|
const isTestData = data.reimbursementId?.includes('test') || data.reimbursementId === 'test-id';
|
||||||
|
|
||||||
|
let reimbursement, user;
|
||||||
|
|
||||||
|
if (isTestData) {
|
||||||
|
console.log('🧪 Using test data for demonstration');
|
||||||
|
// Use mock data for testing
|
||||||
|
reimbursement = {
|
||||||
|
id: data.reimbursementId,
|
||||||
|
title: 'Test Reimbursement Request',
|
||||||
|
total_amount: 125.50,
|
||||||
|
date_of_purchase: new Date().toISOString(),
|
||||||
|
department: 'general',
|
||||||
|
payment_method: 'Personal Card',
|
||||||
|
status: data.previousStatus || 'submitted',
|
||||||
|
submitted_by: 'test-user-id',
|
||||||
|
audit_notes: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
user = {
|
||||||
|
id: 'test-user-id',
|
||||||
|
name: 'Test User',
|
||||||
|
email: data.additionalContext?.testEmail || 'test@example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('✅ Test data prepared:', {
|
||||||
|
reimbursementTitle: reimbursement.title,
|
||||||
|
userEmail: user.email
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Get real reimbursement details
|
||||||
|
console.log('🔍 Fetching reimbursement details for:', data.reimbursementId);
|
||||||
|
reimbursement = await pb.collection('reimbursement').getOne(data.reimbursementId);
|
||||||
|
console.log('✅ Reimbursement fetched:', { id: reimbursement.id, title: reimbursement.title });
|
||||||
|
|
||||||
|
// Get submitter user details
|
||||||
|
console.log('👤 Fetching user details for:', reimbursement.submitted_by);
|
||||||
|
user = await pb.collection('users').getOne(reimbursement.submitted_by);
|
||||||
|
if (!user || !user.email) {
|
||||||
|
console.error('❌ User not found or no email:', reimbursement.submitted_by);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log('✅ User fetched:', { id: user.id, name: user.name, email: user.email });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get changed by user name if provided
|
||||||
|
let changedByName = 'System';
|
||||||
|
if (data.changedByUserId) {
|
||||||
|
try {
|
||||||
|
const changedByUser = await pb.collection('users').getOne(data.changedByUserId);
|
||||||
|
changedByName = changedByUser?.name || 'Unknown User';
|
||||||
|
console.log('👤 Changed by user:', changedByName);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Could not get changed by user name:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = `Reimbursement Status Updated: ${reimbursement.title}`;
|
||||||
|
const statusColor = getStatusColor(data.newStatus);
|
||||||
|
const statusText = getStatusText(data.newStatus);
|
||||||
|
|
||||||
|
console.log('📝 Email details:', {
|
||||||
|
to: user.email,
|
||||||
|
subject,
|
||||||
|
status: data.newStatus
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add audit note when reimbursement is declined (skip for test data)
|
||||||
|
if (data.newStatus === 'rejected' && !isTestData) {
|
||||||
|
try {
|
||||||
|
console.log('📝 Adding audit note for declined reimbursement...');
|
||||||
|
|
||||||
|
// Prepare audit note content
|
||||||
|
let auditNote = `Status changed to REJECTED by ${changedByName}`;
|
||||||
|
if (data.additionalContext?.rejectionReason) {
|
||||||
|
auditNote += `\nRejection Reason: ${data.additionalContext.rejectionReason}`;
|
||||||
|
}
|
||||||
|
auditNote += `\nDate: ${new Date().toLocaleString()}`;
|
||||||
|
|
||||||
|
// Get existing audit notes or initialize empty string
|
||||||
|
const existingNotes = reimbursement.audit_notes || '';
|
||||||
|
const updatedNotes = existingNotes
|
||||||
|
? `${existingNotes}\n\n--- DECLINE RECORD ---\n${auditNote}`
|
||||||
|
: `--- DECLINE RECORD ---\n${auditNote}`;
|
||||||
|
|
||||||
|
// Update the reimbursement record with the new audit notes
|
||||||
|
await pb.collection('reimbursement').update(data.reimbursementId, {
|
||||||
|
audit_notes: updatedNotes
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Audit note added successfully for declined reimbursement');
|
||||||
|
} catch (auditError) {
|
||||||
|
console.error('❌ Failed to add audit note for declined reimbursement:', auditError);
|
||||||
|
// Don't fail the entire email process if audit note fails
|
||||||
|
}
|
||||||
|
} else if (data.newStatus === 'rejected' && isTestData) {
|
||||||
|
console.log('🧪 Skipping audit note update for test data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to generate status progress bar HTML (email-compatible)
|
||||||
|
function generateStatusProgressBar(currentStatus: string): string {
|
||||||
|
const statusOrder = ['submitted', 'under_review', 'approved', 'in_progress', 'paid'];
|
||||||
|
const rejectedStatus = ['submitted', 'under_review', 'rejected'];
|
||||||
|
|
||||||
|
const isRejected = currentStatus === 'rejected';
|
||||||
|
const statuses = isRejected ? rejectedStatus : statusOrder;
|
||||||
|
|
||||||
|
const statusIcons: Record<string, string> = {
|
||||||
|
submitted: '→',
|
||||||
|
under_review: '?',
|
||||||
|
approved: '✓',
|
||||||
|
rejected: '✗',
|
||||||
|
in_progress: '○',
|
||||||
|
paid: '$'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
submitted: 'Submitted',
|
||||||
|
under_review: 'Under Review',
|
||||||
|
approved: 'Approved',
|
||||||
|
rejected: 'Rejected',
|
||||||
|
in_progress: 'In Progress',
|
||||||
|
paid: 'Paid'
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentIndex = statuses.indexOf(currentStatus);
|
||||||
|
|
||||||
|
let progressBarHtml = `
|
||||||
|
<div style="background: #f8fafc; padding: 30px 20px; border-radius: 8px; margin: 20px 0; border: 1px solid #e2e8f0;">
|
||||||
|
<h3 style="margin: 0 0 30px 0; color: #1e293b; font-size: 16px; font-weight: 600; text-align: center;">Request Progress</h3>
|
||||||
|
<table style="width: 100%; max-width: 500px; margin: 0 auto; border-collapse: collapse; position: relative;">
|
||||||
|
<tr style="position: relative;">
|
||||||
|
<td colspan="${statuses.length * 2 - 1}" style="height: 2px; background: #e2e8f0; position: absolute; top: 21px; left: 0; right: 0; z-index: 3;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr style="position: relative; z-index: 1;">
|
||||||
|
`;
|
||||||
|
|
||||||
|
statuses.forEach((status, index) => {
|
||||||
|
const isActive = index <= currentIndex;
|
||||||
|
const isCurrent = status === currentStatus;
|
||||||
|
|
||||||
|
let backgroundColor, textColor, lineColor;
|
||||||
|
if (isCurrent) {
|
||||||
|
if (status === 'rejected') {
|
||||||
|
backgroundColor = '#ef4444';
|
||||||
|
textColor = 'white';
|
||||||
|
lineColor = '#ef4444';
|
||||||
|
} else if (status === 'paid') {
|
||||||
|
backgroundColor = '#10b981';
|
||||||
|
textColor = 'white';
|
||||||
|
lineColor = '#10b981';
|
||||||
|
} else if (status === 'in_progress') {
|
||||||
|
backgroundColor = '#f59e0b';
|
||||||
|
textColor = 'white';
|
||||||
|
lineColor = '#f59e0b';
|
||||||
|
} else {
|
||||||
|
backgroundColor = '#3b82f6';
|
||||||
|
textColor = 'white';
|
||||||
|
lineColor = '#3b82f6';
|
||||||
|
}
|
||||||
|
} else if (isActive) {
|
||||||
|
backgroundColor = '#e2e8f0';
|
||||||
|
textColor = '#475569';
|
||||||
|
lineColor = '#cbd5e1';
|
||||||
|
} else {
|
||||||
|
backgroundColor = '#f8fafc';
|
||||||
|
textColor = '#94a3b8';
|
||||||
|
lineColor = '#e2e8f0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status circle
|
||||||
|
progressBarHtml += `
|
||||||
|
<td style="text-align: center; padding: 0; vertical-align: top; position: relative; width: ${100/statuses.length}%;">
|
||||||
|
<div style="position: relative; z-index: 1; padding: 5px 0;">
|
||||||
|
<div style="
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${backgroundColor};
|
||||||
|
color: ${textColor};
|
||||||
|
text-align: center;
|
||||||
|
line-height: 32px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
border: 3px solid #f8fafc;
|
||||||
|
box-shadow: none;
|
||||||
|
margin: 0 auto 8px auto;
|
||||||
|
">
|
||||||
|
${statusIcons[status]}
|
||||||
|
</div>
|
||||||
|
<div style="
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${isCurrent ? (status === 'rejected' ? '#ef4444' : status === 'paid' ? '#10b981' : status === 'in_progress' ? '#f59e0b' : '#3b82f6') : isActive ? '#475569' : '#94a3b8'};
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
">
|
||||||
|
${statusLabels[status]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Connecting line (except for the last status)
|
||||||
|
if (index < statuses.length - 1) {
|
||||||
|
const nextIsActive = (index + 1) <= currentIndex;
|
||||||
|
const connectionColor = nextIsActive ? lineColor : '#e2e8f0';
|
||||||
|
|
||||||
|
progressBarHtml += `
|
||||||
|
<td style="padding: 0; vertical-align: top; position: relative; width: 20px;">
|
||||||
|
<div style="
|
||||||
|
height: 2px;
|
||||||
|
background: ${connectionColor};
|
||||||
|
position: absolute;
|
||||||
|
top: 21px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 3;
|
||||||
|
"></div>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
progressBarHtml += `
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return progressBarHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Update</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">Status Update</h2>
|
||||||
|
<p>Hello ${user.name},</p>
|
||||||
|
<p>Your reimbursement request "<strong>${reimbursement.title}</strong>" has been updated.</p>
|
||||||
|
|
||||||
|
${data.useImageProgress ?
|
||||||
|
`<div style="text-align: center; margin: 20px 0;">
|
||||||
|
<img src="${getStatusImageUrl(data.newStatus, 'https://ieeeatucsd.org')}" alt="Request Progress" style="max-width: 100%; height: auto; border-radius: 8px;" />
|
||||||
|
</div>` :
|
||||||
|
generateStatusProgressBar(data.newStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${statusColor}; margin: 20px 0;">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<span style="font-weight: bold; color: #666;">Status:</span>
|
||||||
|
<span style="background: ${statusColor}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500; margin-left: 10px;">${statusText}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${data.previousStatus && data.previousStatus !== data.newStatus ? `
|
||||||
|
<div style="color: #666; font-size: 14px;">
|
||||||
|
Changed from: <span style="text-decoration: line-through;">${getStatusText(data.previousStatus)}</span> → <strong>${statusText}</strong>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${changedByName !== 'System' ? `
|
||||||
|
<div style="color: #666; font-size: 14px; margin-top: 10px;">
|
||||||
|
Updated by: ${changedByName}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${data.newStatus === 'rejected' && data.additionalContext?.rejectionReason ? `
|
||||||
|
<div style="background: #f8d7da; padding: 15px; border-radius: 6px; border: 1px solid #f5c6cb; margin-top: 15px;">
|
||||||
|
<div style="font-weight: bold; color: #721c24; margin-bottom: 8px;">Rejection Reason:</div>
|
||||||
|
<div style="color: #721c24; font-style: italic;">${data.additionalContext.rejectionReason}</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 25px 0;">
|
||||||
|
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Amount:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Payment Method:</td>
|
||||||
|
<td style="padding: 8px 0;">${reimbursement.payment_method}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; font-size: 14px;"><strong>Next Steps:</strong></p>
|
||||||
|
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
||||||
|
${getNextStepsText(data.newStatus)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('Attempting to send email via Resend...');
|
||||||
|
const result = await resend.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to: [user.email],
|
||||||
|
replyTo: replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Resend response:', result);
|
||||||
|
console.log('Status change email sent successfully!');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send status change email:', error);
|
||||||
|
console.error('Error details:', {
|
||||||
|
name: error instanceof Error ? error.name : 'Unknown',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCommentEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log('Starting comment email process...');
|
||||||
|
console.log('Comment data received:', {
|
||||||
|
reimbursementId: data.reimbursementId,
|
||||||
|
commentByUserId: data.commentByUserId,
|
||||||
|
isPrivate: data.isPrivate,
|
||||||
|
commentLength: data.comment?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't send emails for private comments
|
||||||
|
if (data.isPrivate) {
|
||||||
|
console.log('Comment is private, skipping email notification');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reimbursement details
|
||||||
|
console.log('Fetching reimbursement details for:', data.reimbursementId);
|
||||||
|
const reimbursement = await pb.collection('reimbursement').getOne(data.reimbursementId);
|
||||||
|
console.log('Reimbursement fetched:', {
|
||||||
|
id: reimbursement.id,
|
||||||
|
title: reimbursement.title,
|
||||||
|
submitted_by: reimbursement.submitted_by
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get submitter user details
|
||||||
|
console.log('Fetching submitter user details for:', reimbursement.submitted_by);
|
||||||
|
const user = await pb.collection('users').getOne(reimbursement.submitted_by);
|
||||||
|
if (!user || !user.email) {
|
||||||
|
console.error('User not found or no email:', reimbursement.submitted_by);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log('Submitter user fetched:', {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get commenter user name
|
||||||
|
console.log('Fetching commenter user details for:', data.commentByUserId);
|
||||||
|
let commentByName = 'Unknown User';
|
||||||
|
try {
|
||||||
|
const commentByUser = await pb.collection('users').getOne(data.commentByUserId);
|
||||||
|
commentByName = commentByUser?.name || 'Unknown User';
|
||||||
|
console.log('Commenter user fetched:', {
|
||||||
|
id: commentByUser?.id,
|
||||||
|
name: commentByName
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not get commenter user name:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = `New Comment on Reimbursement: ${reimbursement.title}`;
|
||||||
|
|
||||||
|
console.log('Comment email details:', {
|
||||||
|
to: user.email,
|
||||||
|
subject,
|
||||||
|
commentBy: commentByName,
|
||||||
|
commentPreview: data.comment.substring(0, 50) + (data.comment.length > 50 ? '...' : '')
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Comment</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">New Comment Added</h2>
|
||||||
|
<p>Hello ${user.name},</p>
|
||||||
|
<p>A new comment has been added to your reimbursement request "<strong>${reimbursement.title}</strong>".</p>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #3498db; margin: 20px 0;">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<span style="font-weight: bold; color: #2980b9;">Comment by:</span> ${commentByName}
|
||||||
|
</div>
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px;">
|
||||||
|
<p style="margin: 0; font-style: italic;">${data.comment}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 25px 0;">
|
||||||
|
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Status:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">
|
||||||
|
<span style="background: ${getStatusColor(reimbursement.status)}; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
||||||
|
${getStatusText(reimbursement.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Date of Purchase:</td>
|
||||||
|
<td style="padding: 8px 0;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('Attempting to send comment email via Resend...');
|
||||||
|
const result = await resend.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to: [user.email],
|
||||||
|
replyTo: replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Resend comment email response:', result);
|
||||||
|
console.log('Comment email sent successfully!');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send comment email:', error);
|
||||||
|
console.error('Comment email error details:', {
|
||||||
|
name: error instanceof Error ? error.name : 'Unknown',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSubmissionEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Get reimbursement details
|
||||||
|
const reimbursement = await pb.collection('reimbursement').getOne(data.reimbursementId);
|
||||||
|
|
||||||
|
// Get submitter user details
|
||||||
|
const user = await pb.collection('users').getOne(reimbursement.submitted_by);
|
||||||
|
if (!user || !user.email) {
|
||||||
|
console.error('User not found or no email:', reimbursement.submitted_by);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send confirmation email to submitter
|
||||||
|
const submitterSubject = `Reimbursement Submitted: ${reimbursement.title}`;
|
||||||
|
|
||||||
|
const submitterHtml = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${submitterSubject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">Reimbursement Submitted Successfully</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">Submission Confirmed</h2>
|
||||||
|
<p>Hello ${user.name},</p>
|
||||||
|
<p>Your reimbursement request has been successfully submitted and is now under review.</p>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0; color: #155724;">Reimbursement Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Title:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.title}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Payment Method:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.payment_method}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Status:</td>
|
||||||
|
<td style="padding: 8px 0;">
|
||||||
|
<span style="background: #ffc107; color: #212529; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
||||||
|
Submitted
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
||||||
|
<h4 style="margin: 0 0 10px 0; color: #155724;">What happens next?</h4>
|
||||||
|
<ul style="margin: 0; padding-left: 20px; color: #155724;">
|
||||||
|
<li>Your receipts will be reviewed by our team</li>
|
||||||
|
<li>You'll receive email updates as the status changes</li>
|
||||||
|
<li>Once approved, payment will be processed</li>
|
||||||
|
<li>Typical processing time is 1-2 weeks</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Send notification email to treasurer
|
||||||
|
const treasurerSubject = `New Reimbursement Request: ${reimbursement.title} - $${reimbursement.total_amount.toFixed(2)}`;
|
||||||
|
|
||||||
|
const treasurerHtml = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${treasurerSubject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">New Reimbursement Request</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">Action Required</h2>
|
||||||
|
<p>Hello Treasurer,</p>
|
||||||
|
<p>A new reimbursement request has been submitted and is awaiting review.</p>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #007bff; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0; color: #004085;">Reimbursement Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Submitted by:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${user.name} (${user.email})</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Title:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.title}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; color: #28a745;">$${reimbursement.total_amount.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Payment Method:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.payment_method}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Submitted:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.created).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Status:</td>
|
||||||
|
<td style="padding: 8px 0;">
|
||||||
|
<span style="background: #ffc107; color: #212529; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
||||||
|
Submitted - Awaiting Review
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
${reimbursement.additional_info ? `
|
||||||
|
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #eee;">
|
||||||
|
<h4 style="margin: 0 0 10px 0; color: #495057;">Additional Information:</h4>
|
||||||
|
<div style="background: #f8f9fa; padding: 12px; border-radius: 6px; font-style: italic;">
|
||||||
|
${reimbursement.additional_info}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #e7f3ff; padding: 15px; border-radius: 8px; border-left: 4px solid #007bff; margin: 20px 0;">
|
||||||
|
<h4 style="margin: 0 0 10px 0; color: #004085;">Next Steps:</h4>
|
||||||
|
<ul style="margin: 0; padding-left: 20px; color: #004085;">
|
||||||
|
<li>Review the submitted receipts and documentation</li>
|
||||||
|
<li>Log into the reimbursement portal to approve or request changes</li>
|
||||||
|
<li>The submitter will be notified of any status updates</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you have any questions, please contact the submitter directly at <a href="mailto:${user.email}" style="color: #667eea;">${user.email}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Send both emails
|
||||||
|
const submitterResult = await resend.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to: [user.email],
|
||||||
|
replyTo: replyToEmail,
|
||||||
|
subject: submitterSubject,
|
||||||
|
html: submitterHtml,
|
||||||
|
});
|
||||||
|
|
||||||
|
const treasurerResult = await resend.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to: ['treasurer@ieeeatucsd.org'],
|
||||||
|
replyTo: user.email, // Set reply-to as the submitter for treasurer's convenience
|
||||||
|
subject: treasurerSubject,
|
||||||
|
html: treasurerHtml,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Submission confirmation email sent successfully:', submitterResult);
|
||||||
|
console.log('Treasurer notification email sent successfully:', treasurerResult);
|
||||||
|
|
||||||
|
// Return true if at least one email was sent successfully
|
||||||
|
return !!(submitterResult && treasurerResult);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send submission emails:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTestEmail(resend: any, fromEmail: string, replyToEmail: string, email: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log('Starting test email process...');
|
||||||
|
console.log('Test email configuration:', {
|
||||||
|
fromEmail,
|
||||||
|
replyToEmail,
|
||||||
|
toEmail: email,
|
||||||
|
hasResend: !!resend
|
||||||
|
});
|
||||||
|
|
||||||
|
const subject = 'Test Email from IEEE UCSD Reimbursement System';
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">Test Email</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">Email System Test</h2>
|
||||||
|
<p>This is a test email from the IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you receive this email, the notification system is working correctly!</p>
|
||||||
|
|
||||||
|
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; color: #155724;">Email delivery successful</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is a test notification from IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('Sending test email via Resend...');
|
||||||
|
const result = await resend.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to: [email],
|
||||||
|
replyTo: replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Resend test email response:', result);
|
||||||
|
console.log('Test email sent successfully!');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send test email:', error);
|
||||||
|
console.error('Test email error details:', {
|
||||||
|
name: error instanceof Error ? error.name : 'Unknown',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
98
src/pages/api/email/send-reimbursement-notification.ts
Normal file
98
src/pages/api/email/send-reimbursement-notification.ts
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
console.log('📨 Email notification API called (legacy endpoint)');
|
||||||
|
|
||||||
|
const requestData = await request.json();
|
||||||
|
const { type, reimbursementId, eventRequestId } = requestData;
|
||||||
|
|
||||||
|
console.log('📋 Request data:', {
|
||||||
|
type,
|
||||||
|
reimbursementId,
|
||||||
|
eventRequestId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
console.error('❌ Missing required parameter: type');
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing required parameter: type' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which endpoint to redirect to based on email type
|
||||||
|
const reimbursementTypes = ['status_change', 'comment', 'submission', 'test'];
|
||||||
|
const eventRequestTypes = ['event_request_submission', 'event_request_status_change', 'pr_completed', 'design_pr_notification'];
|
||||||
|
const officerTypes = ['officer_role_change'];
|
||||||
|
|
||||||
|
let targetEndpoint = '';
|
||||||
|
|
||||||
|
if (reimbursementTypes.includes(type)) {
|
||||||
|
if (!reimbursementId && type !== 'test') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing reimbursementId for reimbursement notification' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
targetEndpoint = '/api/email/send-reimbursement-email';
|
||||||
|
} else if (eventRequestTypes.includes(type)) {
|
||||||
|
if (!eventRequestId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing eventRequestId for event request notification' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
targetEndpoint = '/api/email/send-event-request-email';
|
||||||
|
} else if (officerTypes.includes(type)) {
|
||||||
|
const { officerId } = requestData;
|
||||||
|
if (!officerId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing officerId for officer notification' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
targetEndpoint = '/api/email/send-officer-notification';
|
||||||
|
} else {
|
||||||
|
console.error('❌ Unknown notification type:', type);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Unknown notification type: ${type}` }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔄 Redirecting ${type} to ${targetEndpoint}`);
|
||||||
|
|
||||||
|
// Forward the request to the appropriate endpoint
|
||||||
|
const baseUrl = new URL(request.url).origin;
|
||||||
|
const response = await fetch(`${baseUrl}${targetEndpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
console.log(`📊 Forwarded request result: ${result.success ? 'SUCCESS' : 'FAILED'}`);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify(result),
|
||||||
|
{
|
||||||
|
status: response.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in email notification API:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
258
src/pages/api/generate-status-image.ts
Normal file
258
src/pages/api/generate-status-image.ts
Normal file
|
@ -0,0 +1,258 @@
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
try {
|
||||||
|
const searchParams = new URL(url).searchParams;
|
||||||
|
const status = searchParams.get('status') || 'submitted';
|
||||||
|
const width = parseInt(searchParams.get('width') || '500');
|
||||||
|
const height = parseInt(searchParams.get('height') || '150');
|
||||||
|
|
||||||
|
console.log('🎨 Generating SVG status image for:', { status, width, height });
|
||||||
|
|
||||||
|
// Generate SVG status progress bar
|
||||||
|
function generateSVGProgressBar(currentStatus: string): string {
|
||||||
|
const statusOrder = ['submitted', 'under_review', 'approved', 'in_progress', 'paid'];
|
||||||
|
const rejectedStatus = ['submitted', 'under_review', 'rejected'];
|
||||||
|
|
||||||
|
const isRejected = currentStatus === 'rejected';
|
||||||
|
const statuses = isRejected ? rejectedStatus : statusOrder;
|
||||||
|
|
||||||
|
const statusIcons: Record<string, string> = {
|
||||||
|
submitted: '→',
|
||||||
|
under_review: '?',
|
||||||
|
approved: '✓',
|
||||||
|
rejected: '✗',
|
||||||
|
in_progress: '○',
|
||||||
|
paid: '$'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
submitted: 'Submitted',
|
||||||
|
under_review: 'Under Review',
|
||||||
|
approved: 'Approved',
|
||||||
|
rejected: 'Rejected',
|
||||||
|
in_progress: 'In Progress',
|
||||||
|
paid: 'Paid'
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentIndex = statuses.indexOf(currentStatus);
|
||||||
|
const circleRadius = 44; // Double for 2x resolution
|
||||||
|
const lineY = height;
|
||||||
|
const totalWidth = width * 1.6; // Double for 2x resolution (80% of doubled width)
|
||||||
|
const startX = width * 0.2; // Double for 2x resolution (10% of doubled width)
|
||||||
|
const stepWidth = totalWidth / (statuses.length - 1);
|
||||||
|
|
||||||
|
let svgElements = '';
|
||||||
|
|
||||||
|
// Generate background line (behind circles) - doubled for 2x resolution
|
||||||
|
svgElements += `<line x1="${startX}" y1="${lineY + 2}" x2="${startX + totalWidth}" y2="${lineY + 2}" stroke="#e2e8f0" stroke-width="8" opacity="0.6"/>`;
|
||||||
|
|
||||||
|
// Generate progress line up to current status
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
const progressEndX = startX + (currentIndex * stepWidth);
|
||||||
|
let progressColor = '#3b82f6'; // Default blue
|
||||||
|
|
||||||
|
// Set progress color based on current status
|
||||||
|
if (currentStatus === 'rejected') {
|
||||||
|
progressColor = '#ef4444';
|
||||||
|
} else if (currentStatus === 'paid') {
|
||||||
|
progressColor = '#10b981';
|
||||||
|
} else if (currentStatus === 'in_progress') {
|
||||||
|
progressColor = '#f59e0b';
|
||||||
|
}
|
||||||
|
|
||||||
|
svgElements += `<line x1="${startX}" y1="${lineY + 2}" x2="${progressEndX}" y2="${lineY + 2}" stroke="${progressColor}" stroke-width="6" opacity="0.9"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate status circles and labels
|
||||||
|
statuses.forEach((statusName, index) => {
|
||||||
|
const x = startX + (index * stepWidth);
|
||||||
|
const y = lineY;
|
||||||
|
const isActive = index <= currentIndex;
|
||||||
|
const isCurrent = statusName === currentStatus;
|
||||||
|
|
||||||
|
let backgroundColor, textColor;
|
||||||
|
if (isCurrent) {
|
||||||
|
if (statusName === 'rejected') {
|
||||||
|
backgroundColor = '#ef4444';
|
||||||
|
textColor = 'white';
|
||||||
|
} else if (statusName === 'paid') {
|
||||||
|
backgroundColor = '#10b981';
|
||||||
|
textColor = 'white';
|
||||||
|
} else if (statusName === 'in_progress') {
|
||||||
|
backgroundColor = '#f59e0b';
|
||||||
|
textColor = 'white';
|
||||||
|
} else {
|
||||||
|
backgroundColor = '#3b82f6';
|
||||||
|
textColor = 'white';
|
||||||
|
}
|
||||||
|
} else if (isActive) {
|
||||||
|
backgroundColor = '#e2e8f0';
|
||||||
|
textColor = '#475569';
|
||||||
|
} else {
|
||||||
|
backgroundColor = '#f8fafc';
|
||||||
|
textColor = '#94a3b8';
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelColor = isCurrent ?
|
||||||
|
(statusName === 'rejected' ? '#ef4444' :
|
||||||
|
statusName === 'paid' ? '#10b981' :
|
||||||
|
statusName === 'in_progress' ? '#f59e0b' : '#3b82f6') :
|
||||||
|
isActive ? '#475569' : '#94a3b8';
|
||||||
|
|
||||||
|
// Circle with shadow effect
|
||||||
|
svgElements += `<circle cx="${x}" cy="${y}" r="${circleRadius}" fill="${backgroundColor}" stroke="white" stroke-width="6" filter="url(#shadow)"/>`;
|
||||||
|
|
||||||
|
// Icon text - properly centered with dominant-baseline (doubled font size)
|
||||||
|
svgElements += `<text x="${x}" y="${y}" text-anchor="middle" dominant-baseline="central" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="36" font-weight="bold" fill="${textColor}">${statusIcons[statusName]}</text>`;
|
||||||
|
|
||||||
|
// Label text (doubled font size)
|
||||||
|
svgElements += `<text x="${x}" y="${y + circleRadius + 36}" text-anchor="middle" dominant-baseline="central" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="22" font-weight="600" fill="${labelColor}">${statusLabels[statusName]}</text>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
text {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!-- Drop shadow filter -->
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="4" stdDeviation="6" flood-opacity="0.3"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Title (doubled font size) -->
|
||||||
|
<text x="${width}" y="50" text-anchor="middle" dominant-baseline="central" font-size="32" font-weight="700" fill="#1e293b">Request Progress</text>
|
||||||
|
|
||||||
|
${svgElements}
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = generateSVGProgressBar(status);
|
||||||
|
|
||||||
|
console.log('✅ SVG generated, converting to PNG with Puppeteer...');
|
||||||
|
|
||||||
|
// Convert SVG to PNG using Puppeteer
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage', // Overcome limited resource problems
|
||||||
|
'--disable-accelerated-2d-canvas',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-zygote',
|
||||||
|
'--single-process', // For limited memory environments
|
||||||
|
'--disable-gpu',
|
||||||
|
'--disable-web-security',
|
||||||
|
'--disable-features=VizDisplayCompositor',
|
||||||
|
'--disable-extensions',
|
||||||
|
'--disable-default-apps',
|
||||||
|
'--disable-sync',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
'--force-device-scale-factor=2' // Higher DPI for better quality
|
||||||
|
],
|
||||||
|
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, // Allow custom Chromium path
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🚀 Puppeteer browser launched successfully');
|
||||||
|
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
// Set high-resolution viewport for better quality
|
||||||
|
await page.setViewport({
|
||||||
|
width: width * 2, // Double resolution for crisp images
|
||||||
|
height: height * 2,
|
||||||
|
deviceScaleFactor: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('📄 Setting SVG content...');
|
||||||
|
|
||||||
|
// Create HTML wrapper for the SVG
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
width: ${width * 2}px;
|
||||||
|
height: ${height * 2}px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
width: ${width * 2}px;
|
||||||
|
height: ${height * 2}px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${svg.replace(`width="${width}" height="${height}"`, `width="${width * 2}" height="${height * 2}"`)}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Set HTML content
|
||||||
|
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||||
|
|
||||||
|
console.log('📸 Taking screenshot...');
|
||||||
|
|
||||||
|
// Take high-quality screenshot with transparent background
|
||||||
|
const screenshot = await page.screenshot({
|
||||||
|
type: 'png',
|
||||||
|
fullPage: false,
|
||||||
|
omitBackground: true, // Transparent background
|
||||||
|
clip: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: width * 2,
|
||||||
|
height: height * 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('🔒 Browser closed successfully');
|
||||||
|
|
||||||
|
console.log('✅ PNG image generated successfully from SVG');
|
||||||
|
|
||||||
|
return new Response(screenshot, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error generating SVG status image:', error);
|
||||||
|
console.error('Error details:', {
|
||||||
|
name: error instanceof Error ? error.name : 'Unknown',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return more detailed error information
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Failed to generate status image',
|
||||||
|
details: errorMessage,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
82
src/pages/api/logout.ts
Normal file
82
src/pages/api/logout.ts
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
// Mark this endpoint as server-rendered, not static
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request, redirect }) => {
|
||||||
|
try {
|
||||||
|
// Get the Logto endpoint and client ID from environment variables
|
||||||
|
const logtoEndpoint = import.meta.env.LOGTO_ENDPOINT;
|
||||||
|
const clientId = import.meta.env.LOGTO_POCKETBASE_APP_ID;
|
||||||
|
|
||||||
|
if (!logtoEndpoint) {
|
||||||
|
throw new Error("LOGTO_ENDPOINT environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
throw new Error(
|
||||||
|
"LOGTO_POCKETBASE_APP_ID environment variable is not set",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current origin to use as the redirect URL
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const origin = url.origin;
|
||||||
|
|
||||||
|
// Construct the redirect URL (back to dashboard)
|
||||||
|
const redirectUrl = `${origin}/dashboard`;
|
||||||
|
|
||||||
|
// Log the redirect URL for debugging
|
||||||
|
console.log(`Setting post-logout redirect to: ${redirectUrl}`);
|
||||||
|
console.log(`Using client ID: ${clientId}`);
|
||||||
|
|
||||||
|
// Make a POST request to the Logto session end endpoint with the redirect in the body
|
||||||
|
const logoutUrl = `${logtoEndpoint}/oidc/session/end`;
|
||||||
|
|
||||||
|
console.log(`Using Logto endpoint: ${logtoEndpoint}`);
|
||||||
|
console.log(`Full logout URL: ${logoutUrl}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to make a POST request with the redirect in the body and client ID
|
||||||
|
const response = await fetch(logoutUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
post_logout_redirect_uri: redirectUrl,
|
||||||
|
client_id: clientId,
|
||||||
|
}),
|
||||||
|
redirect: "manual", // Don't automatically follow redirects
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we get a redirect response, follow it
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get("Location");
|
||||||
|
if (location) {
|
||||||
|
console.log(`Received redirect to: ${location}`);
|
||||||
|
return redirect(location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If POST doesn't work, fall back to the query parameter approach
|
||||||
|
console.log(
|
||||||
|
"POST request didn't result in expected redirect, falling back to GET",
|
||||||
|
);
|
||||||
|
return redirect(
|
||||||
|
`${logoutUrl}?post_logout_redirect_uri=${encodeURIComponent(redirectUrl)}&client_id=${encodeURIComponent(clientId)}`,
|
||||||
|
);
|
||||||
|
} catch (fetchError) {
|
||||||
|
console.error("Error making POST request to Logto:", fetchError);
|
||||||
|
// Fall back to the query parameter approach
|
||||||
|
return redirect(
|
||||||
|
`${logoutUrl}?post_logout_redirect_uri=${encodeURIComponent(redirectUrl)}&client_id=${encodeURIComponent(clientId)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in logout API:", error);
|
||||||
|
|
||||||
|
// If there's an error, redirect to dashboard anyway
|
||||||
|
return redirect("/dashboard");
|
||||||
|
}
|
||||||
|
};
|
|
@ -9,10 +9,8 @@ import { SendLog } from "../scripts/pocketbase/SendLog";
|
||||||
import { hasAccess, type OfficerStatus } from "../utils/roleAccess";
|
import { hasAccess, type OfficerStatus } from "../utils/roleAccess";
|
||||||
import { OfficerTypes } from "../schemas/pocketbase/schema";
|
import { OfficerTypes } from "../schemas/pocketbase/schema";
|
||||||
import { initAuthSync } from "../scripts/database/initAuthSync";
|
import { initAuthSync } from "../scripts/database/initAuthSync";
|
||||||
import { initTheme } from "../scripts/database/initTheme";
|
|
||||||
import ToastProvider from "../components/dashboard/universal/ToastProvider";
|
import ToastProvider from "../components/dashboard/universal/ToastProvider";
|
||||||
import FirstTimeLoginManager from "../components/dashboard/universal/FirstTimeLoginManager";
|
import FirstTimeLoginManager from "../components/dashboard/universal/FirstTimeLoginManager";
|
||||||
import ThemeToggle from "../components/dashboard/universal/ThemeToggle";
|
|
||||||
|
|
||||||
const title = "Dashboard";
|
const title = "Dashboard";
|
||||||
|
|
||||||
|
@ -42,7 +40,7 @@ const components = Object.fromEntries(
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
@ -51,10 +49,6 @@ const components = Object.fromEntries(
|
||||||
<script
|
<script
|
||||||
src="https://code.iconify.design/iconify-icon/2.3.0/iconify-icon.min.js"
|
src="https://code.iconify.design/iconify-icon/2.3.0/iconify-icon.min.js"
|
||||||
></script>
|
></script>
|
||||||
<script is:inline>
|
|
||||||
// Set a default theme until IndexedDB loads
|
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-base-200">
|
<body class="bg-base-200">
|
||||||
<!-- First Time Login Manager - This handles the onboarding popup for new users -->
|
<!-- First Time Login Manager - This handles the onboarding popup for new users -->
|
||||||
|
@ -157,8 +151,12 @@ const components = Object.fromEntries(
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav class="flex-1 overflow-y-auto scrollbar-hide py-6">
|
<nav
|
||||||
<ul class="menu gap-2 px-4 text-base-content/80">
|
class="flex-1 overflow-y-auto scrollbar-hide py-6 flex flex-col"
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
class="menu gap-2 px-4 text-base-content/80 flex-1 flex flex-col"
|
||||||
|
>
|
||||||
<!-- Loading Skeleton -->
|
<!-- Loading Skeleton -->
|
||||||
<div id="menuLoadingSkeleton">
|
<div id="menuLoadingSkeleton">
|
||||||
{
|
{
|
||||||
|
@ -244,6 +242,20 @@ const components = Object.fromEntries(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{/* Add Logout Button to the bottom of the menu */}
|
||||||
|
<li class="mt-auto">
|
||||||
|
<button
|
||||||
|
class="dashboard-nav-btn gap-4 transition-all duration-200 outline-none focus:outline-none hover:bg-opacity-5 text-error"
|
||||||
|
data-section="logout"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="heroicons:arrow-left-on-rectangle"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</div>
|
</div>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
@ -393,7 +405,6 @@ const components = Object.fromEntries(
|
||||||
import { hasAccess, type OfficerStatus } from "../utils/roleAccess";
|
import { hasAccess, type OfficerStatus } from "../utils/roleAccess";
|
||||||
import { OfficerTypes } from "../schemas/pocketbase/schema";
|
import { OfficerTypes } from "../schemas/pocketbase/schema";
|
||||||
import { initAuthSync } from "../scripts/database/initAuthSync";
|
import { initAuthSync } from "../scripts/database/initAuthSync";
|
||||||
import { initTheme } from "../scripts/database/initTheme";
|
|
||||||
|
|
||||||
const auth = Authentication.getInstance();
|
const auth = Authentication.getInstance();
|
||||||
const get = Get.getInstance();
|
const get = Get.getInstance();
|
||||||
|
@ -404,11 +415,6 @@ const components = Object.fromEntries(
|
||||||
window.toast = () => {};
|
window.toast = () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize theme from IndexedDB
|
|
||||||
initTheme().catch((err) =>
|
|
||||||
console.error("Error initializing theme:", err)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initialize page state
|
// Initialize page state
|
||||||
const pageLoadingState =
|
const pageLoadingState =
|
||||||
document.getElementById("pageLoadingState");
|
document.getElementById("pageLoadingState");
|
||||||
|
@ -469,6 +475,180 @@ const components = Object.fromEntries(
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Function to delete all cookies (to handle Logto logout)
|
||||||
|
const deleteAllCookies = () => {
|
||||||
|
// Get all cookies
|
||||||
|
const cookies = document.cookie.split(";");
|
||||||
|
|
||||||
|
// Common paths that might have cookies
|
||||||
|
const paths = ["/", "/dashboard", "/auth", "/api"];
|
||||||
|
|
||||||
|
// Domains to target
|
||||||
|
const domains = [
|
||||||
|
"", // current domain
|
||||||
|
"auth.ieeeucsd.org",
|
||||||
|
".auth.ieeeucsd.org",
|
||||||
|
"ieeeucsd.org",
|
||||||
|
".ieeeucsd.org",
|
||||||
|
"dev.ieeeucsd.org",
|
||||||
|
".dev.ieeeucsd.org",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Delete each cookie with all combinations of paths and domains
|
||||||
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
|
const cookie = cookies[i];
|
||||||
|
const eqPos = cookie.indexOf("=");
|
||||||
|
const name =
|
||||||
|
eqPos > -1
|
||||||
|
? cookie.substring(0, eqPos).trim()
|
||||||
|
: cookie.trim();
|
||||||
|
|
||||||
|
if (!name) continue; // Skip empty cookie names
|
||||||
|
|
||||||
|
// Try all combinations of paths and domains
|
||||||
|
for (const path of paths) {
|
||||||
|
// Delete from current domain (no domain specified)
|
||||||
|
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=${path}`;
|
||||||
|
|
||||||
|
// Try with specific domains
|
||||||
|
for (const domain of domains) {
|
||||||
|
if (domain) {
|
||||||
|
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=${path};domain=${domain}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specifically target known Logto cookies
|
||||||
|
const logtoSpecificCookies = [
|
||||||
|
"logto",
|
||||||
|
"logto.signin",
|
||||||
|
"logto.session",
|
||||||
|
"logto.callback",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const cookieName of logtoSpecificCookies) {
|
||||||
|
for (const path of paths) {
|
||||||
|
document.cookie = `${cookieName}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=${path}`;
|
||||||
|
|
||||||
|
for (const domain of domains) {
|
||||||
|
if (domain) {
|
||||||
|
document.cookie = `${cookieName}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=${path};domain=${domain}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to create and show a logout confirmation modal
|
||||||
|
const showLogoutConfirmation = () => {
|
||||||
|
// Create modal if it doesn't exist
|
||||||
|
let modal = document.getElementById("logoutConfirmModal");
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement("dialog");
|
||||||
|
modal.id = "logoutConfirmModal";
|
||||||
|
modal.className = "modal modal-bottom sm:modal-middle";
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg">Confirm Logout</h3>
|
||||||
|
<p class="py-4">Are you sure you want to log out of your account?</p>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button id="cancelLogout" class="btn btn-outline">Cancel</button>
|
||||||
|
<button id="confirmLogout" class="btn btn-error">
|
||||||
|
<span id="logoutSpinner" class="loading loading-spinner loading-sm hidden"></span>
|
||||||
|
<span id="logoutText">Log Out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button>Close</button>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
document
|
||||||
|
.getElementById("cancelLogout")
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
(modal as HTMLDialogElement).close();
|
||||||
|
});
|
||||||
|
|
||||||
|
document
|
||||||
|
.getElementById("confirmLogout")
|
||||||
|
?.addEventListener("click", async () => {
|
||||||
|
// Show loading state
|
||||||
|
const spinner =
|
||||||
|
document.getElementById("logoutSpinner");
|
||||||
|
const text = document.getElementById("logoutText");
|
||||||
|
const confirmBtn =
|
||||||
|
document.getElementById("confirmLogout");
|
||||||
|
const cancelBtn =
|
||||||
|
document.getElementById("cancelLogout");
|
||||||
|
|
||||||
|
if (spinner) spinner.classList.remove("hidden");
|
||||||
|
if (text) text.textContent = "Logging out...";
|
||||||
|
if (confirmBtn)
|
||||||
|
confirmBtn.setAttribute("disabled", "true");
|
||||||
|
if (cancelBtn)
|
||||||
|
cancelBtn.setAttribute("disabled", "true");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Log the logout action
|
||||||
|
await logger.send(
|
||||||
|
"logout",
|
||||||
|
"auth",
|
||||||
|
"User logged out from dashboard menu"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log out from PocketBase using the Authentication class
|
||||||
|
await auth.logout();
|
||||||
|
|
||||||
|
// For extra safety, also directly clear the PocketBase auth store
|
||||||
|
const pb = auth.getPocketBase();
|
||||||
|
pb.authStore.clear();
|
||||||
|
|
||||||
|
// Delete all cookies to ensure Logto is logged out
|
||||||
|
deleteAllCookies();
|
||||||
|
|
||||||
|
// Redirect to our API logout endpoint which will properly sign out from Logto
|
||||||
|
window.location.href = "/api/logout";
|
||||||
|
return; // Stop execution here as we're redirecting
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during logout:", error);
|
||||||
|
|
||||||
|
// Show error message if toast is available
|
||||||
|
if (
|
||||||
|
window.toast &&
|
||||||
|
typeof window.toast === "function"
|
||||||
|
) {
|
||||||
|
window.toast(
|
||||||
|
"Failed to log out. Please try again.",
|
||||||
|
{
|
||||||
|
type: "error",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset button state
|
||||||
|
if (spinner) spinner.classList.add("hidden");
|
||||||
|
if (text) text.textContent = "Log Out";
|
||||||
|
if (confirmBtn)
|
||||||
|
confirmBtn.removeAttribute("disabled");
|
||||||
|
if (cancelBtn)
|
||||||
|
cancelBtn.removeAttribute("disabled");
|
||||||
|
|
||||||
|
// Close the modal
|
||||||
|
if (modal) (modal as HTMLDialogElement).close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
if (modal) (modal as HTMLDialogElement).showModal();
|
||||||
|
};
|
||||||
|
|
||||||
// Handle navigation
|
// Handle navigation
|
||||||
const handleNavigation = () => {
|
const handleNavigation = () => {
|
||||||
const navButtons =
|
const navButtons =
|
||||||
|
@ -488,8 +668,7 @@ const components = Object.fromEntries(
|
||||||
|
|
||||||
// Handle logout button
|
// Handle logout button
|
||||||
if (sectionKey === "logout") {
|
if (sectionKey === "logout") {
|
||||||
auth.logout();
|
showLogoutConfirmation();
|
||||||
window.location.reload();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -898,14 +1077,6 @@ const components = Object.fromEntries(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle logout button click
|
|
||||||
document
|
|
||||||
.getElementById("logoutButton")
|
|
||||||
?.addEventListener("click", () => {
|
|
||||||
auth.logout();
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle responsive sidebar
|
// Handle responsive sidebar
|
||||||
if (sidebar) {
|
if (sidebar) {
|
||||||
if (window.innerWidth < 1024) {
|
if (window.innerWidth < 1024) {
|
||||||
|
|
15
src/pages/dashboard/officers.astro
Normal file
15
src/pages/dashboard/officers.astro
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import OfficerManagement from "../../components/dashboard/OfficerManagement/OfficerManagement";
|
||||||
|
|
||||||
|
const title = "Officer Management";
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={title}>
|
||||||
|
<main class="p-4 md:p-8 max-w-6xl mx-auto">
|
||||||
|
<h1 class="text-3xl font-bold mb-6">{title}</h1>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<OfficerManagement client:load />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</Layout>
|
|
@ -3,15 +3,25 @@ import Layout from "../layouts/Layout.astro";
|
||||||
const title = "Authenticating...";
|
const title = "Authenticating...";
|
||||||
---
|
---
|
||||||
|
|
||||||
<main class="min-h-screen flex items-center justify-center">
|
<html lang="en" data-theme="dark">
|
||||||
<div id="content" class="text-center">
|
<head>
|
||||||
<p class="text-2xl font-medium">Redirecting to dashboard...</p>
|
<meta charset="UTF-8" />
|
||||||
<div class="mt-4">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<div class="loading loading-spinner loading-lg"></div>
|
<title>{title} | IEEE UCSD</title>
|
||||||
</div>
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
</div>
|
</head>
|
||||||
</main>
|
<body class="bg-base-200">
|
||||||
<script>
|
<main class="min-h-screen flex items-center justify-center">
|
||||||
import { RedirectHandler } from "../scripts/auth/RedirectHandler";
|
<div id="content" class="text-center">
|
||||||
new RedirectHandler();
|
<p class="text-2xl font-medium">Redirecting to dashboard...</p>
|
||||||
</script>
|
<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>
|
||||||
|
|
156
src/pages/status-images.astro
Normal file
156
src/pages/status-images.astro
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
---
|
||||||
|
// Status Images Display Page
|
||||||
|
const statuses = [
|
||||||
|
'submitted',
|
||||||
|
'under_review',
|
||||||
|
'approved',
|
||||||
|
'in_progress',
|
||||||
|
'paid',
|
||||||
|
'rejected'
|
||||||
|
];
|
||||||
|
|
||||||
|
const imageWidth = 500;
|
||||||
|
const imageHeight = 150;
|
||||||
|
---
|
||||||
|
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
<title>Reimbursement Status Images</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f8fafc;
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #1e293b;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: #475569;
|
||||||
|
margin: 40px 0 20px 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid #e2e8f0;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||||
|
gap: 30px;
|
||||||
|
margin-bottom: 50px;
|
||||||
|
}
|
||||||
|
.status-item {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.status-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.status-image {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.image-url {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.demo-section {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin: 30px 0;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.email-demo {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Reimbursement Status Progress Images</h1>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h2>🖼️ PNG Status Progress Images</h2>
|
||||||
|
<p>High-quality PNG images generated from SVG using Puppeteer with transparent backgrounds:</p>
|
||||||
|
|
||||||
|
<div class="status-grid">
|
||||||
|
{statuses.map((status) => (
|
||||||
|
<div class="status-item">
|
||||||
|
<h3 class="status-title">
|
||||||
|
{status.replace('_', ' ')}
|
||||||
|
</h3>
|
||||||
|
<img
|
||||||
|
src={`/api/generate-status-image?status=${status}&width=${imageWidth}&height=${imageHeight}`}
|
||||||
|
alt={`Status progress for ${status}`}
|
||||||
|
class="status-image"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div class="image-url">
|
||||||
|
🖼️ PNG API: /api/generate-status-image?status={status}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h2>📨 Email Integration Demo</h2>
|
||||||
|
<p>Here's how the PNG images look when embedded in an email-like environment:</p>
|
||||||
|
|
||||||
|
<div class="email-demo" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
|
||||||
|
<h3 style="color: white; margin: 0 0 20px 0;">IEEE UCSD Reimbursement Update</h3>
|
||||||
|
<p style="color: #f1f5f9; margin-bottom: 20px;">Your reimbursement request has been updated:</p>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={`/api/generate-status-image?status=approved&width=500&height=150`}
|
||||||
|
alt="Status progress embedded in email"
|
||||||
|
style="width: 100%; max-width: 500px; height: auto; border-radius: 8px;"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p style="color: #f1f5f9; margin-top: 20px; font-size: 14px;">
|
||||||
|
✨ PNG images with transparent backgrounds work perfectly in all email clients
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -32,7 +32,7 @@ export interface User extends BaseRecord {
|
||||||
major?: string;
|
major?: string;
|
||||||
zelle_information?: string;
|
zelle_information?: string;
|
||||||
last_login?: string;
|
last_login?: string;
|
||||||
points?: number; // Total points earned from events
|
// points?: number; // Total points earned from events (DEPRECATED)
|
||||||
notification_preferences?: string; // JSON string of notification settings
|
notification_preferences?: string; // JSON string of notification settings
|
||||||
display_preferences?: string; // JSON string of display settings (theme, font size, etc.)
|
display_preferences?: string; // JSON string of display settings (theme, font size, etc.)
|
||||||
accessibility_settings?: string; // JSON string of accessibility settings (color blind mode, reduced motion)
|
accessibility_settings?: string; // JSON string of accessibility settings (color blind mode, reduced motion)
|
||||||
|
@ -41,6 +41,18 @@ export interface User extends BaseRecord {
|
||||||
requested_email?: boolean; // Whether the user has requested an IEEE email address
|
requested_email?: boolean; // Whether the user has requested an IEEE email address
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limited User Collection
|
||||||
|
* Represents limited user information for public display
|
||||||
|
* Collection ID: pbc_2802685943
|
||||||
|
*/
|
||||||
|
export interface LimitedUser extends BaseRecord {
|
||||||
|
name: string;
|
||||||
|
major: string;
|
||||||
|
points: string; // JSON string
|
||||||
|
total_events_attended: string; // JSON string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Events Collection
|
* Events Collection
|
||||||
* Represents events created in the system
|
* Represents events created in the system
|
||||||
|
@ -56,6 +68,7 @@ export interface Event extends BaseRecord {
|
||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
published: boolean;
|
published: boolean;
|
||||||
|
event_type: string; // social, technical, outreach, professional, projects, other
|
||||||
has_food: boolean;
|
has_food: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -96,16 +109,17 @@ export interface EventRequest extends BaseRecord {
|
||||||
event_description: string;
|
event_description: string;
|
||||||
flyers_needed: boolean;
|
flyers_needed: boolean;
|
||||||
flyer_type?: string[]; // digital_with_social, digital_no_social, physical_with_advertising, physical_no_advertising, newsletter, other
|
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_advertising_start_date?: string;
|
||||||
flyer_additional_requests?: string;
|
flyer_additional_requests?: string;
|
||||||
|
flyers_completed?: boolean; // Track if flyers have been completed by PR team
|
||||||
photography_needed: boolean;
|
photography_needed: boolean;
|
||||||
required_logos?: string[]; // IEEE, AS, HKN, TESC, PIB, TNT, SWE, OTHER
|
required_logos?: string[]; // IEEE, AS, HKN, TESC, PIB, TNT, SWE, OTHER
|
||||||
other_logos?: string[]; // Array of logo IDs
|
other_logos?: string[]; // Array of logo IDs
|
||||||
advertising_format?: string;
|
advertising_format?: string;
|
||||||
will_or_have_room_booking?: boolean;
|
will_or_have_room_booking?: boolean;
|
||||||
expected_attendance?: number;
|
expected_attendance?: number;
|
||||||
room_booking?: string; // signle file
|
room_booking_files?: string[]; // CHANGED: Multiple files instead of single file
|
||||||
as_funding_required: boolean;
|
as_funding_required: boolean;
|
||||||
food_drinks_being_served: boolean;
|
food_drinks_being_served: boolean;
|
||||||
itemized_invoice?: string; // JSON string
|
itemized_invoice?: string; // JSON string
|
||||||
|
@ -114,6 +128,7 @@ export interface EventRequest extends BaseRecord {
|
||||||
needs_graphics?: boolean;
|
needs_graphics?: boolean;
|
||||||
needs_as_funding?: boolean;
|
needs_as_funding?: boolean;
|
||||||
status: "submitted" | "pending" | "completed" | "declined";
|
status: "submitted" | "pending" | "completed" | "declined";
|
||||||
|
declined_reason?: string; // Reason for declining the event request
|
||||||
requested_user?: string;
|
requested_user?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -217,6 +232,7 @@ export const Collections = {
|
||||||
REIMBURSEMENTS: "reimbursement",
|
REIMBURSEMENTS: "reimbursement",
|
||||||
RECEIPTS: "receipts",
|
RECEIPTS: "receipts",
|
||||||
SPONSORS: "sponsors",
|
SPONSORS: "sponsors",
|
||||||
|
LIMITED_USERS: "limitedUser",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -106,6 +106,7 @@ export class DataSyncService {
|
||||||
filter: string = "",
|
filter: string = "",
|
||||||
sort: string = "-created",
|
sort: string = "-created",
|
||||||
expand: Record<string, any> | string[] | string = {},
|
expand: Record<string, any> | string[] | string = {},
|
||||||
|
detectDeletions: boolean = true,
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
// Skip in non-browser environments
|
// Skip in non-browser environments
|
||||||
if (!isBrowser) {
|
if (!isBrowser) {
|
||||||
|
@ -154,7 +155,7 @@ export class DataSyncService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get data from PocketBase
|
// Get data from PocketBase with expanded relations
|
||||||
const items = await this.get.getAll<T>(collection, filter, sort, {
|
const items = await this.get.getAll<T>(collection, filter, sort, {
|
||||||
expand: normalizedExpand,
|
expand: normalizedExpand,
|
||||||
});
|
});
|
||||||
|
@ -169,12 +170,15 @@ export class DataSyncService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get existing items to handle conflicts
|
// Get existing items to handle conflicts and deletions
|
||||||
const existingItems = await table.toArray();
|
const existingItems = await table.toArray();
|
||||||
const existingItemsMap = new Map(
|
const existingItemsMap = new Map(
|
||||||
existingItems.map((item) => [item.id, item]),
|
existingItems.map((item) => [item.id, item]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Create a set of server item IDs for efficient deletion detection
|
||||||
|
const serverItemIds = new Set(items.map(item => item.id));
|
||||||
|
|
||||||
// Handle conflicts and merge changes
|
// Handle conflicts and merge changes
|
||||||
const itemsToStore = await Promise.all(
|
const itemsToStore = await Promise.all(
|
||||||
items.map(async (item) => {
|
items.map(async (item) => {
|
||||||
|
@ -206,7 +210,43 @@ export class DataSyncService {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store in IndexedDB
|
// Handle deletions: find items that exist locally but not on server
|
||||||
|
// Only detect deletions when:
|
||||||
|
// 1. detectDeletions is true AND
|
||||||
|
// 2. No filter is applied (full collection sync) OR filter is a user-specific filter
|
||||||
|
const shouldDetectDeletions = detectDeletions && (!filter || filter.includes('requested_user=') || filter.includes('user='));
|
||||||
|
|
||||||
|
if (shouldDetectDeletions) {
|
||||||
|
const itemsToDelete = existingItems.filter(localItem => {
|
||||||
|
// For user-specific filters, only delete items that match the filter criteria
|
||||||
|
// but don't exist on the server
|
||||||
|
if (filter && filter.includes('requested_user=')) {
|
||||||
|
// Extract user ID from filter
|
||||||
|
const userMatch = filter.match(/requested_user="([^"]+)"/);
|
||||||
|
const userId = userMatch ? userMatch[1] : null;
|
||||||
|
|
||||||
|
// Only consider items for deletion if they belong to the same user
|
||||||
|
if (userId && (localItem as any).requested_user === userId) {
|
||||||
|
return !serverItemIds.has(localItem.id);
|
||||||
|
}
|
||||||
|
return false; // Don't delete items that don't match the user filter
|
||||||
|
}
|
||||||
|
|
||||||
|
// For full collection syncs, delete any item not on the server
|
||||||
|
return !serverItemIds.has(localItem.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Perform deletions
|
||||||
|
if (itemsToDelete.length > 0) {
|
||||||
|
// console.log(`Deleting ${itemsToDelete.length} items from ${collection} that no longer exist on server`);
|
||||||
|
|
||||||
|
// Delete items that no longer exist on the server
|
||||||
|
const idsToDelete = itemsToDelete.map(item => item.id);
|
||||||
|
await table.bulkDelete(idsToDelete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store/update items from the server
|
||||||
await table.bulkPut(itemsToStore);
|
await table.bulkPut(itemsToStore);
|
||||||
|
|
||||||
// Update last sync timestamp
|
// Update last sync timestamp
|
||||||
|
@ -448,6 +488,7 @@ export class DataSyncService {
|
||||||
filter: string = "",
|
filter: string = "",
|
||||||
sort: string = "-created",
|
sort: string = "-created",
|
||||||
expand: Record<string, any> | string[] | string = {},
|
expand: Record<string, any> | string[] | string = {},
|
||||||
|
detectDeletions: boolean = true,
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
const db = this.dexieService.getDB();
|
const db = this.dexieService.getDB();
|
||||||
const table = this.getTableForCollection(collection);
|
const table = this.getTableForCollection(collection);
|
||||||
|
@ -464,7 +505,7 @@ export class DataSyncService {
|
||||||
|
|
||||||
if (!this.offlineMode && (forceSync || now - lastSync > syncThreshold)) {
|
if (!this.offlineMode && (forceSync || now - lastSync > syncThreshold)) {
|
||||||
try {
|
try {
|
||||||
await this.syncCollection<T>(collection, filter, sort, expand);
|
await this.syncCollection<T>(collection, filter, sort, expand, detectDeletions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error syncing ${collection}, using cached data:`, error);
|
console.error(`Error syncing ${collection}, using cached data:`, error);
|
||||||
}
|
}
|
||||||
|
|
264
src/scripts/email/EmailClient.ts
Normal file
264
src/scripts/email/EmailClient.ts
Normal file
|
@ -0,0 +1,264 @@
|
||||||
|
/**
|
||||||
|
* Client-side helper for sending email notifications via API routes
|
||||||
|
* This runs in the browser and calls the server-side email API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Authentication } from '../pocketbase/Authentication';
|
||||||
|
|
||||||
|
interface EmailNotificationRequest {
|
||||||
|
type: 'status_change' | 'comment' | 'submission' | 'test' | 'event_request_submission' | 'event_request_status_change' | 'pr_completed' | 'design_pr_notification' | 'officer_role_change';
|
||||||
|
reimbursementId?: string;
|
||||||
|
eventRequestId?: string;
|
||||||
|
officerId?: string;
|
||||||
|
previousStatus?: string;
|
||||||
|
newStatus?: string;
|
||||||
|
changedByUserId?: string;
|
||||||
|
comment?: string;
|
||||||
|
commentByUserId?: string;
|
||||||
|
isPrivate?: boolean;
|
||||||
|
declinedReason?: string;
|
||||||
|
additionalContext?: Record<string, any>;
|
||||||
|
authData?: { token: string; model: any };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmailNotificationResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
error?: string;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmailClient {
|
||||||
|
private static getAuthData(): { token: string; model: any } | null {
|
||||||
|
try {
|
||||||
|
const auth = Authentication.getInstance();
|
||||||
|
const token = auth.getAuthToken();
|
||||||
|
const model = auth.getCurrentUser();
|
||||||
|
|
||||||
|
if (token && model) {
|
||||||
|
return { token, model };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not get auth data:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async sendEmailNotification(request: EmailNotificationRequest): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const authData = this.getAuthData();
|
||||||
|
const requestWithAuth = {
|
||||||
|
...request,
|
||||||
|
authData
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/email/send-reimbursement-notification', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestWithAuth),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: EmailNotificationResponse = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Email notification API error:', result.error || result.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.success;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send email notification:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async sendOfficerNotification(request: EmailNotificationRequest): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const authData = this.getAuthData();
|
||||||
|
const requestWithAuth = {
|
||||||
|
...request,
|
||||||
|
authData
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/email/send-officer-notification', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestWithAuth),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: EmailNotificationResponse = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Officer notification API error:', result.error || result.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.success;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send officer notification:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send status change notification
|
||||||
|
*/
|
||||||
|
static async notifyStatusChange(
|
||||||
|
reimbursementId: string,
|
||||||
|
newStatus: string,
|
||||||
|
previousStatus?: string,
|
||||||
|
changedByUserId?: string,
|
||||||
|
additionalContext?: Record<string, any>
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.sendEmailNotification({
|
||||||
|
type: 'status_change',
|
||||||
|
reimbursementId,
|
||||||
|
newStatus,
|
||||||
|
previousStatus,
|
||||||
|
changedByUserId,
|
||||||
|
additionalContext
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send comment notification
|
||||||
|
*/
|
||||||
|
static async notifyComment(
|
||||||
|
reimbursementId: string,
|
||||||
|
comment: string,
|
||||||
|
commentByUserId: string,
|
||||||
|
isPrivate: boolean = false
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.sendEmailNotification({
|
||||||
|
type: 'comment',
|
||||||
|
reimbursementId,
|
||||||
|
comment,
|
||||||
|
commentByUserId,
|
||||||
|
isPrivate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send submission confirmation
|
||||||
|
*/
|
||||||
|
static async notifySubmission(reimbursementId: string): Promise<boolean> {
|
||||||
|
return this.sendEmailNotification({
|
||||||
|
type: 'submission',
|
||||||
|
reimbursementId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send rejection notification with reason
|
||||||
|
*/
|
||||||
|
static async notifyRejection(
|
||||||
|
reimbursementId: string,
|
||||||
|
rejectionReason: string,
|
||||||
|
previousStatus?: string,
|
||||||
|
changedByUserId?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.sendEmailNotification({
|
||||||
|
type: 'status_change',
|
||||||
|
reimbursementId,
|
||||||
|
newStatus: 'rejected',
|
||||||
|
previousStatus,
|
||||||
|
changedByUserId,
|
||||||
|
additionalContext: { rejectionReason }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send test email
|
||||||
|
*/
|
||||||
|
static async sendTestEmail(): Promise<boolean> {
|
||||||
|
return this.sendEmailNotification({
|
||||||
|
type: 'test',
|
||||||
|
reimbursementId: 'test' // Required but not used for test emails
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send event request submission notification to coordinators
|
||||||
|
*/
|
||||||
|
static async notifyEventRequestSubmission(eventRequestId: string): Promise<boolean> {
|
||||||
|
return this.sendEmailNotification({
|
||||||
|
type: 'event_request_submission',
|
||||||
|
eventRequestId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email notification when an event request status is changed
|
||||||
|
*/
|
||||||
|
static async notifyEventRequestStatusChange(
|
||||||
|
eventRequestId: string,
|
||||||
|
previousStatus: string,
|
||||||
|
newStatus: string,
|
||||||
|
changedByUserId?: string,
|
||||||
|
declinedReason?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.sendEmailNotification({
|
||||||
|
type: 'event_request_status_change',
|
||||||
|
eventRequestId,
|
||||||
|
previousStatus,
|
||||||
|
newStatus,
|
||||||
|
changedByUserId,
|
||||||
|
declinedReason
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email notification when PR work is completed for an event request
|
||||||
|
*/
|
||||||
|
static async notifyPRCompleted(eventRequestId: string): Promise<boolean> {
|
||||||
|
return this.sendEmailNotification({
|
||||||
|
type: 'pr_completed',
|
||||||
|
eventRequestId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email notification to design team for PR-related actions
|
||||||
|
*/
|
||||||
|
static async notifyDesignTeam(
|
||||||
|
eventRequestId: string,
|
||||||
|
action: 'submission' | 'pr_update' | 'declined'
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.sendEmailNotification({
|
||||||
|
type: 'design_pr_notification',
|
||||||
|
eventRequestId,
|
||||||
|
additionalContext: { action }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send officer role change notification
|
||||||
|
*/
|
||||||
|
static async notifyOfficerRoleChange(
|
||||||
|
officerId: string,
|
||||||
|
previousRole?: string,
|
||||||
|
previousType?: string,
|
||||||
|
newRole?: string,
|
||||||
|
newType?: string,
|
||||||
|
changedByUserId?: string,
|
||||||
|
isNewOfficer?: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.sendOfficerNotification({
|
||||||
|
type: 'officer_role_change',
|
||||||
|
officerId,
|
||||||
|
additionalContext: {
|
||||||
|
previousRole,
|
||||||
|
previousType,
|
||||||
|
newRole,
|
||||||
|
newType,
|
||||||
|
changedByUserId,
|
||||||
|
isNewOfficer
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
112
src/scripts/email/EmailHelpers.ts
Normal file
112
src/scripts/email/EmailHelpers.ts
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
// Shared email helper functions and utilities
|
||||||
|
|
||||||
|
export function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted': return '#ffc107';
|
||||||
|
case 'under_review': return '#17a2b8';
|
||||||
|
case 'approved': return '#28a745';
|
||||||
|
case 'rejected': return '#dc3545';
|
||||||
|
case 'in_progress': return '#6f42c1';
|
||||||
|
case 'paid': return '#20c997';
|
||||||
|
case 'declined': return '#dc3545';
|
||||||
|
default: return '#6c757d';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusText(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted': return 'Submitted';
|
||||||
|
case 'under_review': return 'Under Review';
|
||||||
|
case 'approved': return 'Approved';
|
||||||
|
case 'rejected': return 'Rejected';
|
||||||
|
case 'in_progress': return 'In Progress';
|
||||||
|
case 'paid': return 'Paid';
|
||||||
|
case 'declined': return 'Declined';
|
||||||
|
default: return status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextStepsText(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted':
|
||||||
|
return 'Your reimbursement is in the queue for review. We\'ll notify you once it\'s being processed.';
|
||||||
|
case 'under_review':
|
||||||
|
return 'Our team is currently reviewing your receipts and documentation. No action needed from you.';
|
||||||
|
case 'approved':
|
||||||
|
return 'Your reimbursement has been approved! Payment processing will begin shortly.';
|
||||||
|
case 'rejected':
|
||||||
|
return 'Your reimbursement has been rejected. Please review the rejection reason above and reach out to our treasurer if you have questions or need to resubmit with corrections.';
|
||||||
|
case 'in_progress':
|
||||||
|
return 'Payment is being processed. You should receive your reimbursement within 1-2 business days.';
|
||||||
|
case 'paid':
|
||||||
|
return 'Your reimbursement has been completed! Please check your account for the payment.';
|
||||||
|
default:
|
||||||
|
return 'Check your dashboard for more details about your reimbursement status.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeEmailServices() {
|
||||||
|
// Import Resend and create direct PocketBase connection for server-side use
|
||||||
|
const { Resend } = await import('resend');
|
||||||
|
const PocketBase = await import('pocketbase').then(module => module.default);
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
const pb = new PocketBase(import.meta.env.POCKETBASE_URL || 'http://127.0.0.1:8090');
|
||||||
|
const resend = new Resend(import.meta.env.RESEND_API_KEY);
|
||||||
|
|
||||||
|
if (!import.meta.env.RESEND_API_KEY) {
|
||||||
|
throw new Error('RESEND_API_KEY environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromEmail = import.meta.env.FROM_EMAIL || 'IEEE UCSD <noreply@ieeeucsd.org>';
|
||||||
|
const replyToEmail = import.meta.env.REPLY_TO_EMAIL || 'treasurer@ieeeucsd.org';
|
||||||
|
|
||||||
|
return { pb, resend, fromEmail, replyToEmail };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authenticatePocketBase(pb: any, authData: any) {
|
||||||
|
if (authData && authData.token && authData.model) {
|
||||||
|
console.log('🔐 Authenticating with PocketBase using provided auth data');
|
||||||
|
pb.authStore.save(authData.token, authData.model);
|
||||||
|
console.log('✅ PocketBase authentication successful');
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ No auth data provided, proceeding without authentication');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(dateString: string): string {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFlyerTypes(flyerTypes: string[]): string {
|
||||||
|
if (!flyerTypes || flyerTypes.length === 0) return 'None specified';
|
||||||
|
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
'digital_with_social': 'Digital with Social Media',
|
||||||
|
'digital_no_social': 'Digital without Social Media',
|
||||||
|
'physical_with_advertising': 'Physical with Advertising',
|
||||||
|
'physical_no_advertising': 'Physical without Advertising',
|
||||||
|
'newsletter': 'Newsletter',
|
||||||
|
'other': 'Other'
|
||||||
|
};
|
||||||
|
|
||||||
|
return flyerTypes.map(type => typeMap[type] || type).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLogos(logos: string[]): string {
|
||||||
|
if (!logos || logos.length === 0) return 'None specified';
|
||||||
|
return logos.join(', ');
|
||||||
|
}
|
410
src/scripts/email/EmailService.ts
Normal file
410
src/scripts/email/EmailService.ts
Normal file
|
@ -0,0 +1,410 @@
|
||||||
|
import { Resend } from 'resend';
|
||||||
|
import type { User, Reimbursement, Receipt } from '../../schemas/pocketbase';
|
||||||
|
|
||||||
|
// Define email template types
|
||||||
|
export type EmailTemplateType =
|
||||||
|
| 'reimbursement_status_changed'
|
||||||
|
| 'reimbursement_comment_added'
|
||||||
|
| 'reimbursement_submitted'
|
||||||
|
| 'reimbursement_approved'
|
||||||
|
| 'reimbursement_rejected'
|
||||||
|
| 'reimbursement_paid';
|
||||||
|
|
||||||
|
// Email template data interfaces
|
||||||
|
export interface StatusChangeEmailData {
|
||||||
|
user: User;
|
||||||
|
reimbursement: Reimbursement;
|
||||||
|
previousStatus: string;
|
||||||
|
newStatus: string;
|
||||||
|
changedBy?: string;
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentEmailData {
|
||||||
|
user: User;
|
||||||
|
reimbursement: Reimbursement;
|
||||||
|
comment: string;
|
||||||
|
commentBy: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReimbursementEmailData {
|
||||||
|
user: User;
|
||||||
|
reimbursement: Reimbursement;
|
||||||
|
receipts?: Receipt[];
|
||||||
|
additionalData?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmailService {
|
||||||
|
private resend: Resend;
|
||||||
|
private fromEmail: string;
|
||||||
|
private replyToEmail: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Initialize Resend with API key from environment
|
||||||
|
// Use import.meta.env as used throughout the Astro project
|
||||||
|
const apiKey = import.meta.env.RESEND_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('RESEND_API_KEY environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resend = new Resend(apiKey);
|
||||||
|
this.fromEmail = import.meta.env.FROM_EMAIL || 'IEEE UCSD <noreply@transactional.ieeeatucsd.org>';
|
||||||
|
this.replyToEmail = import.meta.env.REPLY_TO_EMAIL || 'ieee@ucsd.edu';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static instance: EmailService | null = null;
|
||||||
|
|
||||||
|
public static getInstance(): EmailService {
|
||||||
|
if (!EmailService.instance) {
|
||||||
|
EmailService.instance = new EmailService();
|
||||||
|
}
|
||||||
|
return EmailService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send reimbursement status change notification
|
||||||
|
*/
|
||||||
|
async sendStatusChangeEmail(data: StatusChangeEmailData): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { user, reimbursement, previousStatus, newStatus, changedBy, comment } = data;
|
||||||
|
|
||||||
|
const subject = `Reimbursement Status Updated: ${reimbursement.title}`;
|
||||||
|
const statusColor = this.getStatusColor(newStatus);
|
||||||
|
const statusText = this.getStatusText(newStatus);
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Update</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">Status Update</h2>
|
||||||
|
<p>Hello ${user.name},</p>
|
||||||
|
<p>Your reimbursement request "<strong>${reimbursement.title}</strong>" has been updated.</p>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${statusColor}; margin: 20px 0;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||||
|
<span style="font-weight: bold; color: #666;">Status Change:</span>
|
||||||
|
<span style="background: ${statusColor}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500;">${statusText}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${previousStatus !== newStatus ? `
|
||||||
|
<div style="color: #666; font-size: 14px;">
|
||||||
|
Changed from: <span style="text-decoration: line-through;">${this.getStatusText(previousStatus)}</span> → <strong>${statusText}</strong>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${changedBy ? `
|
||||||
|
<div style="color: #666; font-size: 14px; margin-top: 10px;">
|
||||||
|
Updated by: ${changedBy}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${comment ? `
|
||||||
|
<div style="background: #e8f4fd; padding: 15px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #3498db;">
|
||||||
|
<h4 style="margin: 0 0 10px 0; color: #2980b9;">Additional Note:</h4>
|
||||||
|
<p style="margin: 0; font-style: italic;">${comment}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div style="margin: 25px 0;">
|
||||||
|
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Amount:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Payment Method:</td>
|
||||||
|
<td style="padding: 8px 0;">${reimbursement.payment_method}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; font-size: 14px;"><strong>Next Steps:</strong></p>
|
||||||
|
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
||||||
|
${this.getNextStepsText(newStatus)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you have any questions, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.resend.emails.send({
|
||||||
|
from: this.fromEmail,
|
||||||
|
to: [user.email],
|
||||||
|
replyTo: this.replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Status change email sent successfully:', result);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send status change email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send comment notification email
|
||||||
|
*/
|
||||||
|
async sendCommentEmail(data: CommentEmailData): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { user, reimbursement, comment, commentBy, isPrivate } = data;
|
||||||
|
|
||||||
|
// Don't send email for private comments unless the user is the recipient
|
||||||
|
if (isPrivate) {
|
||||||
|
return true; // Silently skip private comments for now
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = `New Comment on Reimbursement: ${reimbursement.title}`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Comment</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">New Comment Added</h2>
|
||||||
|
<p>Hello ${user.name},</p>
|
||||||
|
<p>A new comment has been added to your reimbursement request "<strong>${reimbursement.title}</strong>".</p>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #3498db; margin: 20px 0;">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<span style="font-weight: bold; color: #2980b9;">Comment by:</span> ${commentBy}
|
||||||
|
</div>
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px;">
|
||||||
|
<p style="margin: 0; font-style: italic;">${comment}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 25px 0;">
|
||||||
|
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Status:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">
|
||||||
|
<span style="background: ${this.getStatusColor(reimbursement.status)}; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
||||||
|
${this.getStatusText(reimbursement.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Date of Purchase:</td>
|
||||||
|
<td style="padding: 8px 0;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you have any questions, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.resend.emails.send({
|
||||||
|
from: this.fromEmail,
|
||||||
|
to: [user.email],
|
||||||
|
replyTo: this.replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Comment email sent successfully:', result);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send comment email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send reimbursement submission confirmation
|
||||||
|
*/
|
||||||
|
async sendSubmissionConfirmation(data: ReimbursementEmailData): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { user, reimbursement } = data;
|
||||||
|
|
||||||
|
const subject = `Reimbursement Submitted: ${reimbursement.title}`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">✅ Reimbursement Submitted Successfully</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">Submission Confirmed</h2>
|
||||||
|
<p>Hello ${user.name},</p>
|
||||||
|
<p>Your reimbursement request has been successfully submitted and is now under review.</p>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0; color: #155724;">Reimbursement Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Title:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.title}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Payment Method:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.payment_method}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Status:</td>
|
||||||
|
<td style="padding: 8px 0;">
|
||||||
|
<span style="background: #ffc107; color: #212529; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
||||||
|
Submitted
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
||||||
|
<h4 style="margin: 0 0 10px 0; color: #155724;">What happens next?</h4>
|
||||||
|
<ul style="margin: 0; padding-left: 20px; color: #155724;">
|
||||||
|
<li>Your receipts will be reviewed by our team</li>
|
||||||
|
<li>You'll receive email updates as the status changes</li>
|
||||||
|
<li>Once approved, payment will be processed</li>
|
||||||
|
<li>Typical processing time is 1-2 weeks</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
||||||
|
<p>If you have any questions, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.resend.emails.send({
|
||||||
|
from: this.fromEmail,
|
||||||
|
to: [user.email],
|
||||||
|
replyTo: this.replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Submission confirmation email sent successfully:', result);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send submission confirmation email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status color for styling
|
||||||
|
*/
|
||||||
|
private getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted': return '#ffc107';
|
||||||
|
case 'under_review': return '#17a2b8';
|
||||||
|
case 'approved': return '#28a745';
|
||||||
|
case 'rejected': return '#dc3545';
|
||||||
|
case 'in_progress': return '#6f42c1';
|
||||||
|
case 'paid': return '#20c997';
|
||||||
|
default: return '#6c757d';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable status text
|
||||||
|
*/
|
||||||
|
private getStatusText(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted': return 'Submitted';
|
||||||
|
case 'under_review': return 'Under Review';
|
||||||
|
case 'approved': return 'Approved';
|
||||||
|
case 'rejected': return 'Rejected';
|
||||||
|
case 'in_progress': return 'In Progress';
|
||||||
|
case 'paid': return 'Paid';
|
||||||
|
default: return status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get next steps text based on status
|
||||||
|
*/
|
||||||
|
private getNextStepsText(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted':
|
||||||
|
return 'Your reimbursement is in the queue for review. We\'ll notify you once it\'s being processed.';
|
||||||
|
case 'under_review':
|
||||||
|
return 'Our team is currently reviewing your receipts and documentation. No action needed from you.';
|
||||||
|
case 'approved':
|
||||||
|
return 'Your reimbursement has been approved! Payment processing will begin shortly.';
|
||||||
|
case 'rejected':
|
||||||
|
return 'Your reimbursement has been rejected. Please review the comments and reach out if you have questions.';
|
||||||
|
case 'in_progress':
|
||||||
|
return 'Payment is being processed. You should receive your reimbursement within 1-2 business days.';
|
||||||
|
case 'paid':
|
||||||
|
return 'Your reimbursement has been completed! Please check your account for the payment.';
|
||||||
|
default:
|
||||||
|
return 'Check your dashboard for more details about your reimbursement status.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
429
src/scripts/email/EventRequestEmailFunctions.ts
Normal file
429
src/scripts/email/EventRequestEmailFunctions.ts
Normal file
|
@ -0,0 +1,429 @@
|
||||||
|
import { getStatusColor, getStatusText, formatDateTime, formatFlyerTypes, formatLogos } from './EmailHelpers';
|
||||||
|
|
||||||
|
export async function sendEventRequestSubmissionEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log('🎪 Starting event request submission email process...');
|
||||||
|
|
||||||
|
// Get event request details
|
||||||
|
const eventRequest = await pb.collection('event_request').getOne(data.eventRequestId);
|
||||||
|
const user = await pb.collection('users').getOne(eventRequest.requested_user);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.error('❌ User not found:', eventRequest.requested_user);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinatorsEmail = 'coordinators@ieeeatucsd.org';
|
||||||
|
const subject = `New Event Request Submitted: ${eventRequest.name}`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">🎪 New Event Request Submitted</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">Event Request Details</h2>
|
||||||
|
<p>Hello Coordinators,</p>
|
||||||
|
<p>A new event request has been submitted by <strong>${user.name}</strong> and requires your review.</p>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0; color: #155724;">Basic Information</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Event Name:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Location:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.location}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Start Date & Time:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${formatDateTime(eventRequest.start_date_time)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">End Date & Time:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${formatDateTime(eventRequest.end_date_time)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Expected Attendance:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.expected_attendance || 'Not specified'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Submitted By:</td>
|
||||||
|
<td style="padding: 8px 0;">${user.name} (${user.email})</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #17a2b8; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0; color: #0c5460;">Event Description</h3>
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px;">
|
||||||
|
<p style="margin: 0; white-space: pre-wrap;">${eventRequest.event_description || 'No description provided'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
||||||
|
<h4 style="margin: 0 0 10px 0; color: #155724;">Next Steps</h4>
|
||||||
|
<ul style="margin: 0; padding-left: 20px; color: #155724;">
|
||||||
|
<li>Review the event request details in the dashboard</li>
|
||||||
|
<li>Coordinate with the submitter if clarification is needed</li>
|
||||||
|
<li>Assign tasks to appropriate team members</li>
|
||||||
|
<li>Update the event request status once processed</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Event Management System.</p>
|
||||||
|
<p>Event Request ID: ${eventRequest.id}</p>
|
||||||
|
<p>If you have any questions, please contact the submitter at <a href="mailto:${user.email}" style="color: #667eea;">${user.email}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await resend.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to: [coordinatorsEmail],
|
||||||
|
replyTo: user.email,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Event request notification email sent successfully!');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to send event request notification email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEventRequestStatusChangeEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log('🎯 Starting event request status change email process...');
|
||||||
|
|
||||||
|
// Get event request details
|
||||||
|
const eventRequest = await pb.collection('event_request').getOne(data.eventRequestId);
|
||||||
|
const user = await pb.collection('users').getOne(eventRequest.requested_user);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.error('❌ User not found:', eventRequest.requested_user);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinatorsEmail = 'coordinators@ieeeatucsd.org';
|
||||||
|
const userSubject = `Your Event Request Status Updated: ${eventRequest.name}`;
|
||||||
|
|
||||||
|
const userHtml = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${userSubject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Event Request Update</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">Status Update</h2>
|
||||||
|
<p>Hello ${user.name},</p>
|
||||||
|
<p>Your event request "<strong>${eventRequest.name}</strong>" has been updated.</p>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${getStatusColor(data.newStatus)}; margin: 20px 0;">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<span style="font-weight: bold; color: #666;">Status:</span>
|
||||||
|
<span style="background: ${getStatusColor(data.newStatus)}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500; margin-left: 10px;">${getStatusText(data.newStatus)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${data.previousStatus && data.previousStatus !== data.newStatus ? `
|
||||||
|
<div style="color: #666; font-size: 14px;">
|
||||||
|
Changed from: <span style="text-decoration: line-through;">${getStatusText(data.previousStatus)}</span> → <strong>${getStatusText(data.newStatus)}</strong>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${data.newStatus === 'declined' && data.declinedReason ? `
|
||||||
|
<div style="background: #f8d7da; padding: 15px; border-radius: 8px; border-left: 4px solid #dc3545; margin: 15px 0;">
|
||||||
|
<p style="margin: 0; color: #721c24;"><strong>Decline Reason:</strong></p>
|
||||||
|
<p style="margin: 5px 0 0 0; color: #721c24;">${data.declinedReason}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 25px 0;">
|
||||||
|
<h3 style="color: #2c3e50; margin-bottom: 15px;">Your Event Request Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Event Name:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Status:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${getStatusText(data.newStatus)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Location:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.location}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Event Date:</td>
|
||||||
|
<td style="padding: 8px 0;">${formatDateTime(eventRequest.start_date_time)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Event Management System.</p>
|
||||||
|
<p>Event Request ID: ${eventRequest.id}</p>
|
||||||
|
<p>If you have any questions, please contact us at <a href="mailto:coordinators@ieeeatucsd.org" style="color: #667eea;">coordinators@ieeeatucsd.org</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Send email to user
|
||||||
|
await resend.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to: [user.email],
|
||||||
|
replyTo: replyToEmail,
|
||||||
|
subject: userSubject,
|
||||||
|
html: userHtml,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email to coordinators
|
||||||
|
const coordinatorSubject = `Event Request Status Updated: ${eventRequest.name}`;
|
||||||
|
await resend.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to: [coordinatorsEmail],
|
||||||
|
replyTo: user.email,
|
||||||
|
subject: coordinatorSubject,
|
||||||
|
html: userHtml.replace(user.name, 'Coordinators').replace('Your event request', `Event request by ${user.name}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Event request status change emails sent successfully!');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to send event request status change email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPRCompletedEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log('🎨 Starting PR completed email process...');
|
||||||
|
|
||||||
|
// Get event request details
|
||||||
|
const eventRequest = await pb.collection('event_request').getOne(data.eventRequestId);
|
||||||
|
const user = await pb.collection('users').getOne(eventRequest.requested_user);
|
||||||
|
|
||||||
|
if (!user || !user.email) {
|
||||||
|
console.error('❌ User not found or no email:', eventRequest.requested_user);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = `PR Materials Completed for Your Event: ${eventRequest.name}`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">🎨 PR Materials Completed!</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">Great News!</h2>
|
||||||
|
<p>Hello ${user.name},</p>
|
||||||
|
<p>The PR materials for your event "<strong>${eventRequest.name}</strong>" have been completed by our PR team!</p>
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<span style="background: #28a745; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500;">
|
||||||
|
✅ PR Materials Completed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 0; color: #155724;">Event Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Event Name:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Location:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.location}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Event Date:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${formatDateTime(eventRequest.start_date_time)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Flyers Needed:</td>
|
||||||
|
<td style="padding: 8px 0;">${eventRequest.flyers_needed ? 'Yes' : 'No'}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;">
|
||||||
|
<h4 style="margin: 0 0 10px 0; color: #856404;">📞 Next Steps</h4>
|
||||||
|
<p style="margin: 0; color: #856404;">
|
||||||
|
<strong>Important:</strong> Please reach out to the Internal team to coordinate any remaining logistics for your event.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0 0 0; color: #856404;">
|
||||||
|
Contact: <strong>internal@ieeeatucsd.org</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Event Management System.</p>
|
||||||
|
<p>Event Request ID: ${eventRequest.id}</p>
|
||||||
|
<p>If you have any questions about your PR materials, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to: [user.email],
|
||||||
|
replyTo: replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ PR completed email sent successfully!');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to send PR completed email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendDesignPRNotificationEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log('🎨 Starting design PR notification email process...');
|
||||||
|
|
||||||
|
// Get event request details
|
||||||
|
const eventRequest = await pb.collection('event_request').getOne(data.eventRequestId);
|
||||||
|
const user = await pb.collection('users').getOne(eventRequest.requested_user);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.error('❌ User not found:', eventRequest.requested_user);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const designEmail = 'design@ieeeatucsd.org';
|
||||||
|
let subject = '';
|
||||||
|
let actionMessage = '';
|
||||||
|
|
||||||
|
switch (data.action) {
|
||||||
|
case 'submission':
|
||||||
|
subject = `New Event Request with PR Materials: ${eventRequest.name}`;
|
||||||
|
actionMessage = 'A new event request has been submitted that requires PR materials.';
|
||||||
|
break;
|
||||||
|
case 'pr_update':
|
||||||
|
subject = `PR Materials Updated: ${eventRequest.name}`;
|
||||||
|
actionMessage = 'The PR materials for this event request have been updated.';
|
||||||
|
break;
|
||||||
|
case 'declined':
|
||||||
|
subject = `Event Request Declined - PR Work Cancelled: ${eventRequest.name}`;
|
||||||
|
actionMessage = 'This event request has been declined. Please ignore any pending PR work for this event.';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
subject = `Event Request PR Notification: ${eventRequest.name}`;
|
||||||
|
actionMessage = 'There has been an update to an event request requiring PR materials.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">🎨 IEEE UCSD Design Team Notification</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">PR Materials ${data.action === 'declined' ? 'Cancelled' : 'Required'}</h2>
|
||||||
|
<p>Hello Design Team,</p>
|
||||||
|
<p>${actionMessage}</p>
|
||||||
|
|
||||||
|
<div style="margin: 25px 0;">
|
||||||
|
<h3 style="color: #2c3e50; margin-bottom: 15px;">Event Request Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Event Name:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Action:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${data.action.charAt(0).toUpperCase() + data.action.slice(1)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Submitted By:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${user.name} (${user.email})</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Event Description:</td>
|
||||||
|
<td style="padding: 8px 0;">${eventRequest.event_description}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${data.action !== 'declined' ? `
|
||||||
|
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; color: #155724;"><strong>Next Steps:</strong> Please coordinate with the internal team for PR material creation and timeline.</p>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div style="background: #f8d7da; padding: 15px; border-radius: 8px; border-left: 4px solid #dc3545; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; color: #721c24;"><strong>Note:</strong> This event has been declined. No further PR work is needed.</p>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Event Management System.</p>
|
||||||
|
<p>Event Request ID: ${eventRequest.id}</p>
|
||||||
|
<p>If you have any questions, please contact <a href="mailto:internal@ieeeatucsd.org" style="color: #667eea;">internal@ieeeatucsd.org</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to: [designEmail],
|
||||||
|
replyTo: replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Design PR notification email sent successfully!');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to send design PR notification email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
296
src/scripts/email/OfficerEmailNotifications.ts
Normal file
296
src/scripts/email/OfficerEmailNotifications.ts
Normal file
|
@ -0,0 +1,296 @@
|
||||||
|
import { Resend } from 'resend';
|
||||||
|
import type { User, Officer } from '../../schemas/pocketbase/schema';
|
||||||
|
import { OfficerTypes } from '../../schemas/pocketbase';
|
||||||
|
|
||||||
|
// Email template data interfaces
|
||||||
|
export interface OfficerRoleChangeEmailData {
|
||||||
|
user: User;
|
||||||
|
officer: Officer;
|
||||||
|
previousRole?: string;
|
||||||
|
previousType?: string;
|
||||||
|
newRole: string;
|
||||||
|
newType: string;
|
||||||
|
changedBy?: string;
|
||||||
|
isNewOfficer?: boolean; // If this is a new officer appointment
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OfficerEmailNotifications {
|
||||||
|
private resend: Resend;
|
||||||
|
private fromEmail: string;
|
||||||
|
private replyToEmail: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Initialize Resend with API key from environment
|
||||||
|
const apiKey = import.meta.env.RESEND_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('RESEND_API_KEY environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resend = new Resend(apiKey);
|
||||||
|
this.fromEmail = import.meta.env.FROM_EMAIL || 'IEEE UCSD <noreply@transactional.ieeeatucsd.org>';
|
||||||
|
this.replyToEmail = import.meta.env.REPLY_TO_EMAIL || 'ieee@ucsd.edu';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static instance: OfficerEmailNotifications | null = null;
|
||||||
|
|
||||||
|
public static getInstance(): OfficerEmailNotifications {
|
||||||
|
if (!OfficerEmailNotifications.instance) {
|
||||||
|
OfficerEmailNotifications.instance = new OfficerEmailNotifications();
|
||||||
|
}
|
||||||
|
return OfficerEmailNotifications.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send officer role change notification email
|
||||||
|
*/
|
||||||
|
async sendRoleChangeNotification(data: OfficerRoleChangeEmailData): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { user, officer, previousRole, previousType, newRole, newType, changedBy, isNewOfficer } = data;
|
||||||
|
|
||||||
|
const subject = isNewOfficer
|
||||||
|
? `Welcome to IEEE UCSD Leadership - ${newRole}`
|
||||||
|
: `Your IEEE UCSD Officer Role has been Updated`;
|
||||||
|
|
||||||
|
const typeColor = this.getOfficerTypeColor(newType);
|
||||||
|
const typeText = this.getOfficerTypeDisplayName(newType);
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${subject}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Officer Update</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
||||||
|
<h2 style="margin-top: 0; color: #2c3e50;">
|
||||||
|
${isNewOfficer ? 'Welcome to the Team!' : 'Role Update'}
|
||||||
|
</h2>
|
||||||
|
<p>Hello ${user.name},</p>
|
||||||
|
|
||||||
|
${isNewOfficer ? `
|
||||||
|
<p>Congratulations! You have been appointed as an officer for IEEE UCSD. We're excited to have you join our leadership team!</p>
|
||||||
|
` : `
|
||||||
|
<p>Your officer role has been updated in the IEEE UCSD system.</p>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${typeColor}; margin: 20px 0;">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<h3 style="margin: 0 0 10px 0; color: #2c3e50;">Your Current Role</h3>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||||
|
<span style="font-weight: bold; font-size: 18px; color: #2c3e50;">${newRole}</span>
|
||||||
|
<span style="background: ${typeColor}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500;">${typeText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${!isNewOfficer && (previousRole || previousType) ? `
|
||||||
|
<div style="color: #666; font-size: 14px; padding: 10px 0; border-top: 1px solid #eee;">
|
||||||
|
<strong>Previous:</strong> ${previousRole || 'Unknown Role'} (${this.getOfficerTypeDisplayName(previousType || '')})
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${changedBy ? `
|
||||||
|
<div style="color: #666; font-size: 14px; margin-top: 10px;">
|
||||||
|
${isNewOfficer ? 'Appointed' : 'Updated'} by: ${changedBy}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 25px 0;">
|
||||||
|
<h3 style="color: #2c3e50; margin-bottom: 15px;">Officer Information</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Name:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${user.name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Email:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${user.email}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Role:</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${newRole}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Officer Type:</td>
|
||||||
|
<td style="padding: 8px 0;">${typeText}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.getOfficerTypeDescription(newType)}
|
||||||
|
|
||||||
|
<div style="background: #e8f4fd; padding: 15px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #3498db;">
|
||||||
|
<h4 style="margin: 0 0 10px 0; color: #2980b9;">Next Steps:</h4>
|
||||||
|
<ul style="margin: 0; padding-left: 20px;">
|
||||||
|
<li>Check your access to the officer dashboard</li>
|
||||||
|
<li>Familiarize yourself with your new responsibilities</li>
|
||||||
|
<li>Reach out to other officers if you have questions</li>
|
||||||
|
${isNewOfficer ? '<li>Attend the next officer meeting to get up to speed</li>' : ''}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
||||||
|
<p>This is an automated notification from IEEE UCSD Officer Management System.</p>
|
||||||
|
<p>If you have any questions about your role, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.resend.emails.send({
|
||||||
|
from: this.fromEmail,
|
||||||
|
to: [user.email],
|
||||||
|
replyTo: this.replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Officer role change email sent successfully:', result);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send officer role change email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color for officer type badge
|
||||||
|
*/
|
||||||
|
private getOfficerTypeColor(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case OfficerTypes.ADMINISTRATOR:
|
||||||
|
return '#dc3545'; // Red for admin
|
||||||
|
case OfficerTypes.EXECUTIVE:
|
||||||
|
return '#6f42c1'; // Purple for executive
|
||||||
|
case OfficerTypes.GENERAL:
|
||||||
|
return '#007bff'; // Blue for general
|
||||||
|
case OfficerTypes.HONORARY:
|
||||||
|
return '#fd7e14'; // Orange for honorary
|
||||||
|
case OfficerTypes.PAST:
|
||||||
|
return '#6c757d'; // Gray for past
|
||||||
|
default:
|
||||||
|
return '#28a745'; // Green as default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display name for officer type
|
||||||
|
*/
|
||||||
|
private getOfficerTypeDisplayName(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case OfficerTypes.ADMINISTRATOR:
|
||||||
|
return 'Administrator';
|
||||||
|
case OfficerTypes.EXECUTIVE:
|
||||||
|
return 'Executive Officer';
|
||||||
|
case OfficerTypes.GENERAL:
|
||||||
|
return 'General Officer';
|
||||||
|
case OfficerTypes.HONORARY:
|
||||||
|
return 'Honorary Officer';
|
||||||
|
case OfficerTypes.PAST:
|
||||||
|
return 'Past Officer';
|
||||||
|
default:
|
||||||
|
return 'Officer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get description for officer type
|
||||||
|
*/
|
||||||
|
private getOfficerTypeDescription(type: string): string {
|
||||||
|
const baseStyle = "background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;";
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case OfficerTypes.ADMINISTRATOR:
|
||||||
|
return `
|
||||||
|
<div style="${baseStyle}">
|
||||||
|
<p style="margin: 0; font-size: 14px;"><strong>Administrator Role:</strong></p>
|
||||||
|
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
||||||
|
As an administrator, you have full access to manage officers, events, and system settings. You can add/remove other officers and access all administrative features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
case OfficerTypes.EXECUTIVE:
|
||||||
|
return `
|
||||||
|
<div style="${baseStyle}">
|
||||||
|
<p style="margin: 0; font-size: 14px;"><strong>Executive Officer Role:</strong></p>
|
||||||
|
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
||||||
|
As an executive officer, you have leadership responsibilities and access to advanced features in the officer dashboard. You can manage events and participate in key decision-making.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
case OfficerTypes.GENERAL:
|
||||||
|
return `
|
||||||
|
<div style="${baseStyle}">
|
||||||
|
<p style="margin: 0; font-size: 14px;"><strong>General Officer Role:</strong></p>
|
||||||
|
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
||||||
|
As a general officer, you have access to the officer dashboard and can help with event management, member engagement, and other organizational activities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
case OfficerTypes.HONORARY:
|
||||||
|
return `
|
||||||
|
<div style="${baseStyle}">
|
||||||
|
<p style="margin: 0; font-size: 14px;"><strong>Honorary Officer Role:</strong></p>
|
||||||
|
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
||||||
|
As an honorary officer, you are recognized for your contributions to IEEE UCSD. You have access to officer resources and are part of our leadership community.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
case OfficerTypes.PAST:
|
||||||
|
return `
|
||||||
|
<div style="${baseStyle}">
|
||||||
|
<p style="margin: 0; font-size: 14px;"><strong>Past Officer Status:</strong></p>
|
||||||
|
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
||||||
|
Thank you for your service to IEEE UCSD! As a past officer, you maintain access to alumni resources and remain part of our leadership community.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
default:
|
||||||
|
return `
|
||||||
|
<div style="${baseStyle}">
|
||||||
|
<p style="margin: 0; font-size: 14px;"><strong>Officer Role:</strong></p>
|
||||||
|
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
||||||
|
Welcome to the IEEE UCSD officer team! You now have access to officer resources and can contribute to our organization's activities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch notify multiple officers (for bulk operations)
|
||||||
|
*/
|
||||||
|
async notifyBulkRoleChanges(
|
||||||
|
notifications: OfficerRoleChangeEmailData[]
|
||||||
|
): Promise<{ successful: number; failed: number }> {
|
||||||
|
let successful = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const notification of notifications) {
|
||||||
|
try {
|
||||||
|
const result = await this.sendRoleChangeNotification(notification);
|
||||||
|
if (result) {
|
||||||
|
successful++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a small delay to avoid rate limiting
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send bulk notification:', error);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { successful, failed };
|
||||||
|
}
|
||||||
|
}
|
203
src/scripts/email/README.md
Normal file
203
src/scripts/email/README.md
Normal file
|
@ -0,0 +1,203 @@
|
||||||
|
# Email Notification System
|
||||||
|
|
||||||
|
This directory contains the email notification system for the IEEE UCSD reimbursement portal and event management system using Resend.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Add the following environment variables to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PocketBase Configuration
|
||||||
|
POCKETBASE_URL=https://pocketbase.ieeeucsd.org
|
||||||
|
|
||||||
|
# Resend API Configuration
|
||||||
|
RESEND_API_KEY=your_resend_api_key_here
|
||||||
|
|
||||||
|
# Email Configuration
|
||||||
|
FROM_EMAIL="IEEE UCSD <noreply@ieeeucsd.org>"
|
||||||
|
REPLY_TO_EMAIL="treasurer@ieeeucsd.org"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: This project uses Astro's standard environment variable pattern with `import.meta.env.VARIABLE_NAME`. No PUBLIC_ prefix is needed as these are used in API routes and server-side code.
|
||||||
|
|
||||||
|
### Getting a Resend API Key
|
||||||
|
|
||||||
|
1. Sign up for a [Resend account](https://resend.com)
|
||||||
|
2. Go to your dashboard and create a new API key
|
||||||
|
3. Add the API key to your environment variables
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Automatic Email Notifications
|
||||||
|
|
||||||
|
The system automatically sends emails for the following events:
|
||||||
|
|
||||||
|
#### Reimbursement System
|
||||||
|
1. **Reimbursement Submitted** - Confirmation email when a user submits a new reimbursement request
|
||||||
|
2. **Status Changes** - Notification when reimbursement status is updated (submitted, under review, approved, rejected, in progress, paid)
|
||||||
|
3. **Comments Added** - Notification when someone adds a public comment to a reimbursement
|
||||||
|
4. **Rejections with Reasons** - Detailed rejection notification including the specific reason for rejection
|
||||||
|
|
||||||
|
#### Event Management System
|
||||||
|
1. **Event Request Submitted** - Notification to coordinators@ieeeatucsd.org when a new event request is submitted
|
||||||
|
|
||||||
|
Note: Private comments are not sent via email to maintain privacy.
|
||||||
|
|
||||||
|
### Email Templates
|
||||||
|
|
||||||
|
All emails include:
|
||||||
|
- Professional IEEE UCSD branding
|
||||||
|
- Responsive design for mobile and desktop
|
||||||
|
- Clear status indicators with color coding
|
||||||
|
- Detailed information summary
|
||||||
|
- Next steps information
|
||||||
|
- Contact information for support
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### In React Components (Client-side)
|
||||||
|
|
||||||
|
#### Reimbursement Notifications
|
||||||
|
```typescript
|
||||||
|
import { EmailClient } from '../../../scripts/email/EmailClient';
|
||||||
|
|
||||||
|
// Send status change notification
|
||||||
|
await EmailClient.notifyStatusChange(reimbursementId, newStatus, previousStatus, userId);
|
||||||
|
|
||||||
|
// Send comment notification
|
||||||
|
await EmailClient.notifyComment(reimbursementId, comment, commentByUserId, isPrivate);
|
||||||
|
|
||||||
|
// Send submission confirmation
|
||||||
|
await EmailClient.notifySubmission(reimbursementId);
|
||||||
|
|
||||||
|
// Send rejection with reason (recommended for rejections)
|
||||||
|
await EmailClient.notifyRejection(reimbursementId, rejectionReason, previousStatus, userId);
|
||||||
|
|
||||||
|
// Send test email
|
||||||
|
await EmailClient.sendTestEmail('your-email@example.com');
|
||||||
|
|
||||||
|
// Alternative: Send rejection via notifyStatusChange with additionalContext
|
||||||
|
await EmailClient.notifyStatusChange(
|
||||||
|
reimbursementId,
|
||||||
|
'rejected',
|
||||||
|
previousStatus,
|
||||||
|
userId,
|
||||||
|
{ rejectionReason: 'Missing receipt for coffee purchase. Please resubmit with proper documentation.' }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Event Request Notifications
|
||||||
|
```typescript
|
||||||
|
import { EmailClient } from '../../../scripts/email/EmailClient';
|
||||||
|
|
||||||
|
// Send event request submission notification to coordinators
|
||||||
|
await EmailClient.notifyEventRequestSubmission(eventRequestId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Route (Server-side)
|
||||||
|
|
||||||
|
The API route at `/api/email/send-reimbursement-notification` accepts POST requests with the following structure:
|
||||||
|
|
||||||
|
#### Reimbursement Notifications
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "status_change" | "comment" | "submission" | "test",
|
||||||
|
"reimbursementId": "string",
|
||||||
|
"newStatus": "string", // for status_change
|
||||||
|
"previousStatus": "string", // for status_change
|
||||||
|
"changedByUserId": "string", // for status_change
|
||||||
|
"comment": "string", // for comment
|
||||||
|
"commentByUserId": "string", // for comment
|
||||||
|
"isPrivate": boolean, // for comment
|
||||||
|
"additionalContext": {}, // for additional data
|
||||||
|
"authData": { // Authentication data for PocketBase access
|
||||||
|
"token": "string",
|
||||||
|
"model": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Event Request Notifications
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "event_request_submission",
|
||||||
|
"eventRequestId": "string",
|
||||||
|
"authData": { // Authentication data for PocketBase access
|
||||||
|
"token": "string",
|
||||||
|
"model": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The email system uses a client-server architecture for security and authentication:
|
||||||
|
|
||||||
|
- `EmailService.ts` - Core email service using Resend (server-side only)
|
||||||
|
- `ReimbursementEmailNotifications.ts` - High-level notification service (server-side only)
|
||||||
|
- `EmailClient.ts` - Client-side helper that calls the API with authentication
|
||||||
|
- `/api/email/send-reimbursement-notification.ts` - API route that handles server-side email sending with PocketBase authentication
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
1. **Client-side**: `EmailClient` gets the current user's authentication token and model from the `Authentication` service
|
||||||
|
2. **API Request**: The auth data is sent to the server-side API route
|
||||||
|
3. **Server-side**: The API route authenticates with PocketBase using the provided auth data
|
||||||
|
4. **Database Access**: The authenticated PocketBase connection can access protected collections
|
||||||
|
5. **Email Sending**: Emails are sent using the Resend service with proper user data
|
||||||
|
|
||||||
|
This ensures that:
|
||||||
|
- API keys are never exposed to the client-side code
|
||||||
|
- Only authenticated users can trigger email notifications
|
||||||
|
- The server can access protected PocketBase collections
|
||||||
|
- Email operations respect user permissions and data security
|
||||||
|
|
||||||
|
## Email Recipients
|
||||||
|
|
||||||
|
- **Reimbursement notifications**: Sent to the user who submitted the reimbursement
|
||||||
|
- **Event request notifications**: Sent to coordinators@ieeeatucsd.org
|
||||||
|
- **Test emails**: Sent to the specified email address
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Email failures are logged but do not prevent the main operations from completing. This ensures that reimbursement processing and event request submissions continue even if email delivery fails.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- API keys are loaded from environment variables server-side only
|
||||||
|
- Authentication tokens are passed securely from client to server
|
||||||
|
- Email addresses are validated before sending
|
||||||
|
- Private comments are not sent via email (configurable)
|
||||||
|
- All emails include appropriate contact information
|
||||||
|
- PocketBase collection access respects authentication and permissions
|
||||||
|
|
||||||
|
## Event Request Email Notifications
|
||||||
|
|
||||||
|
### Event Request Submission
|
||||||
|
When a new event request is submitted, an email is automatically sent to the coordinators team.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await EmailClient.notifyEventRequestSubmission(eventRequestId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Request Status Change
|
||||||
|
When an event request status is changed, an email is sent to coordinators.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await EmailClient.notifyEventRequestStatusChange(eventRequestId, previousStatus, newStatus, changedByUserId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### PR Completion Notification
|
||||||
|
When PR materials are completed for an event request, an email is sent to the submitter notifying them to contact the internal team.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await EmailClient.notifyPRCompleted(eventRequestId);
|
||||||
|
```
|
||||||
|
|
||||||
|
This email includes:
|
||||||
|
- Confirmation that PR materials are completed
|
||||||
|
- Event details and information
|
||||||
|
- Instructions to contact the internal team for next steps
|
||||||
|
- Contact information for internal@ieeeucsd.org
|
310
src/scripts/email/ReimbursementEmailNotifications.ts
Normal file
310
src/scripts/email/ReimbursementEmailNotifications.ts
Normal file
|
@ -0,0 +1,310 @@
|
||||||
|
import { EmailService, type StatusChangeEmailData, type CommentEmailData, type ReimbursementEmailData } from './EmailService';
|
||||||
|
import { Get } from '../pocketbase/Get';
|
||||||
|
import type { User, Reimbursement, Receipt } from '../../schemas/pocketbase';
|
||||||
|
|
||||||
|
export class ReimbursementEmailNotifications {
|
||||||
|
private emailService: EmailService;
|
||||||
|
private get: Get;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.emailService = EmailService.getInstance();
|
||||||
|
this.get = Get.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static instance: ReimbursementEmailNotifications | null = null;
|
||||||
|
|
||||||
|
public static getInstance(): ReimbursementEmailNotifications {
|
||||||
|
if (!ReimbursementEmailNotifications.instance) {
|
||||||
|
ReimbursementEmailNotifications.instance = new ReimbursementEmailNotifications();
|
||||||
|
}
|
||||||
|
return ReimbursementEmailNotifications.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification when reimbursement status changes
|
||||||
|
*/
|
||||||
|
async notifyStatusChange(
|
||||||
|
reimbursementId: string,
|
||||||
|
previousStatus: string,
|
||||||
|
newStatus: string,
|
||||||
|
changedByUserId?: string,
|
||||||
|
comment?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Get reimbursement details
|
||||||
|
const reimbursement = await this.get.getOne<Reimbursement>('reimbursement', reimbursementId);
|
||||||
|
if (!reimbursement) {
|
||||||
|
console.error('Reimbursement not found:', reimbursementId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get submitter user details
|
||||||
|
const user = await this.get.getOne<User>('users', reimbursement.submitted_by);
|
||||||
|
if (!user || !user.email) {
|
||||||
|
console.error('User not found or no email:', reimbursement.submitted_by);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get changed by user name if provided
|
||||||
|
let changedByName = 'System';
|
||||||
|
if (changedByUserId) {
|
||||||
|
try {
|
||||||
|
const changedByUser = await this.get.getOne<User>('users', changedByUserId);
|
||||||
|
changedByName = changedByUser?.name || 'Unknown User';
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not get changed by user name:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailData: StatusChangeEmailData = {
|
||||||
|
user,
|
||||||
|
reimbursement,
|
||||||
|
previousStatus,
|
||||||
|
newStatus,
|
||||||
|
changedBy: changedByName,
|
||||||
|
comment
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.emailService.sendStatusChangeEmail(emailData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send status change notification:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification when a comment is added to a reimbursement
|
||||||
|
*/
|
||||||
|
async notifyComment(
|
||||||
|
reimbursementId: string,
|
||||||
|
comment: string,
|
||||||
|
commentByUserId: string,
|
||||||
|
isPrivate: boolean = false
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Don't send emails for private comments (for now)
|
||||||
|
if (isPrivate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reimbursement details
|
||||||
|
const reimbursement = await this.get.getOne<Reimbursement>('reimbursement', reimbursementId);
|
||||||
|
if (!reimbursement) {
|
||||||
|
console.error('Reimbursement not found:', reimbursementId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get submitter user details
|
||||||
|
const user = await this.get.getOne<User>('users', reimbursement.submitted_by);
|
||||||
|
if (!user || !user.email) {
|
||||||
|
console.error('User not found or no email:', reimbursement.submitted_by);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't send email if the commenter is the same as the submitter
|
||||||
|
if (commentByUserId === reimbursement.submitted_by) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get commenter user name
|
||||||
|
let commentByName = 'Unknown User';
|
||||||
|
try {
|
||||||
|
const commentByUser = await this.get.getOne<User>('users', commentByUserId);
|
||||||
|
commentByName = commentByUser?.name || 'Unknown User';
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not get commenter user name:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailData: CommentEmailData = {
|
||||||
|
user,
|
||||||
|
reimbursement,
|
||||||
|
comment,
|
||||||
|
commentBy: commentByName,
|
||||||
|
isPrivate
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.emailService.sendCommentEmail(emailData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send comment notification:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send submission confirmation email
|
||||||
|
*/
|
||||||
|
async notifySubmission(reimbursementId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Get reimbursement details
|
||||||
|
const reimbursement = await this.get.getOne<Reimbursement>('reimbursement', reimbursementId);
|
||||||
|
if (!reimbursement) {
|
||||||
|
console.error('Reimbursement not found:', reimbursementId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get submitter user details
|
||||||
|
const user = await this.get.getOne<User>('users', reimbursement.submitted_by);
|
||||||
|
if (!user || !user.email) {
|
||||||
|
console.error('User not found or no email:', reimbursement.submitted_by);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get receipt details if needed
|
||||||
|
let receipts: Receipt[] = [];
|
||||||
|
if (reimbursement.receipts && reimbursement.receipts.length > 0) {
|
||||||
|
try {
|
||||||
|
receipts = await Promise.all(
|
||||||
|
reimbursement.receipts.map(id => this.get.getOne<Receipt>('receipts', id))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not load receipt details:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailData: ReimbursementEmailData = {
|
||||||
|
user,
|
||||||
|
reimbursement,
|
||||||
|
receipts
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.emailService.sendSubmissionConfirmation(emailData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send submission confirmation:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send specific status-based notifications with custom logic
|
||||||
|
*/
|
||||||
|
async notifyByStatus(
|
||||||
|
reimbursementId: string,
|
||||||
|
status: string,
|
||||||
|
previousStatus?: string,
|
||||||
|
triggeredByUserId?: string,
|
||||||
|
additionalContext?: Record<string, any>
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted':
|
||||||
|
return await this.notifySubmission(reimbursementId);
|
||||||
|
|
||||||
|
case 'approved':
|
||||||
|
return await this.notifyStatusChange(
|
||||||
|
reimbursementId,
|
||||||
|
previousStatus || 'under_review',
|
||||||
|
status,
|
||||||
|
triggeredByUserId,
|
||||||
|
'Your reimbursement has been approved and will be processed for payment.'
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'rejected':
|
||||||
|
const rejectionReason = additionalContext?.rejectionReason;
|
||||||
|
return await this.notifyStatusChange(
|
||||||
|
reimbursementId,
|
||||||
|
previousStatus || 'under_review',
|
||||||
|
status,
|
||||||
|
triggeredByUserId,
|
||||||
|
rejectionReason ? `Rejection reason: ${rejectionReason}` : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'paid':
|
||||||
|
return await this.notifyStatusChange(
|
||||||
|
reimbursementId,
|
||||||
|
previousStatus || 'in_progress',
|
||||||
|
status,
|
||||||
|
triggeredByUserId,
|
||||||
|
'Your reimbursement has been completed. Please check your account for the payment.'
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'under_review':
|
||||||
|
case 'in_progress':
|
||||||
|
return await this.notifyStatusChange(
|
||||||
|
reimbursementId,
|
||||||
|
previousStatus || 'submitted',
|
||||||
|
status,
|
||||||
|
triggeredByUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`No specific notification handler for status: ${status}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to send notification for status ${status}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch notify multiple users (for administrative notifications)
|
||||||
|
*/
|
||||||
|
async notifyAdmins(
|
||||||
|
subject: string,
|
||||||
|
message: string,
|
||||||
|
reimbursementId?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// This could be enhanced to get admin user emails from the officers table
|
||||||
|
// For now, we'll just log this functionality
|
||||||
|
console.log('Admin notification requested:', { subject, message, reimbursementId });
|
||||||
|
|
||||||
|
// TODO: Implement admin notification logic
|
||||||
|
// - Get list of admin users from officers table
|
||||||
|
// - Send email to all admins
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send admin notification:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test email functionality (useful for development)
|
||||||
|
*/
|
||||||
|
async testEmail(userEmail: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Create a test user object
|
||||||
|
const testUser: User = {
|
||||||
|
id: 'test-user',
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
email: userEmail,
|
||||||
|
emailVisibility: true,
|
||||||
|
verified: true,
|
||||||
|
name: 'Test User'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a test reimbursement object
|
||||||
|
const testReimbursement: Reimbursement = {
|
||||||
|
id: 'test-reimbursement',
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
title: 'Test Reimbursement',
|
||||||
|
total_amount: 99.99,
|
||||||
|
date_of_purchase: new Date().toISOString(),
|
||||||
|
payment_method: 'Personal Credit Card',
|
||||||
|
status: 'submitted',
|
||||||
|
submitted_by: 'test-user',
|
||||||
|
additional_info: 'This is a test reimbursement for email functionality.',
|
||||||
|
receipts: [],
|
||||||
|
department: 'events'
|
||||||
|
};
|
||||||
|
|
||||||
|
const emailData: StatusChangeEmailData = {
|
||||||
|
user: testUser,
|
||||||
|
reimbursement: testReimbursement,
|
||||||
|
previousStatus: 'submitted',
|
||||||
|
newStatus: 'approved',
|
||||||
|
changedBy: 'Test Admin',
|
||||||
|
comment: 'This is a test email notification.'
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.emailService.sendStatusChangeEmail(emailData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send test email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -37,6 +37,38 @@ export default {
|
||||||
"radial-gradient(circle at 0% 0%, var(--tw-gradient-stops))",
|
"radial-gradient(circle at 0% 0%, var(--tw-gradient-stops))",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
daisyui: {
|
||||||
|
themes: [
|
||||||
|
{
|
||||||
|
light: {
|
||||||
|
primary: "#06659d",
|
||||||
|
secondary: "#4b92db",
|
||||||
|
accent: "#F3C135",
|
||||||
|
neutral: "#2a323c",
|
||||||
|
"base-100": "#ffffff",
|
||||||
|
"base-200": "#f8f9fa",
|
||||||
|
"base-300": "#e9ecef",
|
||||||
|
info: "#3abff8",
|
||||||
|
success: "#36d399",
|
||||||
|
warning: "#fbbd23",
|
||||||
|
error: "#f87272",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
primary: "#88BFEC",
|
||||||
|
secondary: "#4b92db",
|
||||||
|
accent: "#F3C135",
|
||||||
|
neutral: "#191D24",
|
||||||
|
"base-100": "#0A0E1A",
|
||||||
|
"base-200": "#0d1324",
|
||||||
|
"base-300": "#1a2035",
|
||||||
|
info: "#3abff8",
|
||||||
|
success: "#36d399",
|
||||||
|
warning: "#fbbd23",
|
||||||
|
error: "#f87272",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
require("tailwindcss-motion"),
|
require("tailwindcss-motion"),
|
||||||
|
|
Loading…
Reference in a new issue