Compare commits
No commits in common. "main" and "upgrade-tailwind-to-v4" have entirely different histories.
main
...
upgrade-ta
89 changed files with 13330 additions and 8335 deletions
|
@ -1 +0,0 @@
|
||||||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -5,8 +5,6 @@ dist/
|
||||||
.astro/
|
.astro/
|
||||||
.cursor
|
.cursor
|
||||||
|
|
||||||
final_review_gate.py
|
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
|
55
Dockerfile
55
Dockerfile
|
@ -1,55 +0,0 @@
|
||||||
# Use the official Bun image
|
|
||||||
FROM oven/bun:1.1
|
|
||||||
|
|
||||||
# Install dependencies for Puppeteer and Chrome/Chromium
|
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y \
|
|
||||||
wget \
|
|
||||||
ca-certificates \
|
|
||||||
fonts-liberation \
|
|
||||||
libasound2 \
|
|
||||||
libatk-bridge2.0-0 \
|
|
||||||
libatk1.0-0 \
|
|
||||||
libcups2 \
|
|
||||||
libdbus-1-3 \
|
|
||||||
libdrm2 \
|
|
||||||
libgbm1 \
|
|
||||||
libgtk-3-0 \
|
|
||||||
libnspr4 \
|
|
||||||
libnss3 \
|
|
||||||
libx11-xcb1 \
|
|
||||||
libxcomposite1 \
|
|
||||||
libxdamage1 \
|
|
||||||
libxrandr2 \
|
|
||||||
xdg-utils \
|
|
||||||
chromium \
|
|
||||||
gnupg \
|
|
||||||
--no-install-recommends && \
|
|
||||||
# Install Google Chrome stable
|
|
||||||
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - && \
|
|
||||||
echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list && \
|
|
||||||
apt-get update && \
|
|
||||||
apt-get install -y google-chrome-stable && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Set Puppeteer executable path (prefer google-chrome-stable, fallback to chromium)
|
|
||||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files and install dependencies
|
|
||||||
COPY bun.lock package.json ./
|
|
||||||
RUN bun install
|
|
||||||
|
|
||||||
# Copy the rest of your app
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN bun run build
|
|
||||||
|
|
||||||
# Expose the port your app runs on (change if needed)
|
|
||||||
EXPOSE 4321
|
|
||||||
|
|
||||||
# Start the server
|
|
||||||
CMD ["bun", "run", "start"]
|
|
|
@ -1,7 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from "astro/config";
|
import { defineConfig } from "astro/config";
|
||||||
|
|
||||||
import tailwind from "@astrojs/tailwind";
|
|
||||||
|
|
||||||
import mdx from "@astrojs/mdx";
|
import mdx from "@astrojs/mdx";
|
||||||
|
|
||||||
|
@ -13,10 +12,12 @@ import node from "@astrojs/node";
|
||||||
|
|
||||||
import icon from "astro-icon";
|
import icon from "astro-icon";
|
||||||
|
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
output: "server",
|
output: "server",
|
||||||
integrations: [tailwind(), expressiveCode(), react(), icon(), mdx()],
|
integrations: [ expressiveCode(), react(), icon(), mdx()],
|
||||||
|
|
||||||
adapter: node({
|
adapter: node({
|
||||||
mode: "standalone",
|
mode: "standalone",
|
||||||
|
@ -39,5 +40,6 @@ export default defineConfig({
|
||||||
process.env.LOGTO_API_ENDPOINT,
|
process.env.LOGTO_API_ENDPOINT,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
plugins: [tailwindcss()],
|
||||||
},
|
},
|
||||||
});
|
});
|
|
@ -1,12 +0,0 @@
|
||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
|
||||||
web:
|
|
||||||
build: .
|
|
||||||
ports:
|
|
||||||
- "4321:4321"
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=production
|
|
||||||
- PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable
|
|
||||||
- PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
|
||||||
- PUPPETEER_DISABLE_SANDBOX=true
|
|
|
@ -1,4 +1,4 @@
|
||||||
[phases.setup]
|
[phases.setup]
|
||||||
nixPkgs = ["nodejs_20", "bun"]
|
nixPkgs = ["nodejs_18", "bun"]
|
||||||
aptPkgs = ["curl", "wget"]
|
aptPkgs = ["curl", "wget"]
|
||||||
|
|
||||||
|
|
11376
package-lock.json
generated
Normal file
11376
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
@ -2,9 +2,6 @@
|
||||||
"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",
|
||||||
|
@ -13,19 +10,17 @@
|
||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.4",
|
|
||||||
"@astrojs/mdx": "^4.2.3",
|
"@astrojs/mdx": "^4.2.3",
|
||||||
"@astrojs/node": "^9.1.3",
|
"@astrojs/node": "^9.1.3",
|
||||||
"@astrojs/react": "^4.2.3",
|
"@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",
|
||||||
"@iconify/react": "^5.2.0",
|
"@iconify/react": "^5.2.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
"@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/puppeteer": "^7.0.4",
|
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.1",
|
"@types/react-dom": "^19.1.1",
|
||||||
"astro": "^5.5.6",
|
"astro": "^5.5.6",
|
||||||
|
@ -42,20 +37,17 @@
|
||||||
"next": "^15.1.2",
|
"next": "^15.1.2",
|
||||||
"pocketbase": "^0.25.1",
|
"pocketbase": "^0.25.1",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"puppeteer": "^24.10.1",
|
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-chartjs-2": "^5.3.0",
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^19.1.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",
|
||||||
"resend": "^4.5.1",
|
"tailwindcss": "^4.1.4"
|
||||||
"tailwindcss": "^3.4.16",
|
|
||||||
"typescript": "^5.8.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"daisyui": "^4.12.23",
|
"daisyui": "^5.0.28",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-astro": "^0.14.1",
|
"prettier-plugin-astro": "^0.14.1",
|
||||||
"tailwindcss-animated": "^1.1.2",
|
"tailwindcss-animated": "^1.1.2",
|
||||||
|
|
|
@ -22,7 +22,7 @@ const { name, position, picture, email } = Astro.props;
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="md:w-[20vw] w-[35vw] aspect-[334/440] bg-gradient-to-t from-ieee-blue-100/5 to-ieee-blue-100/25 rounded-[10%] flex flex-col items-center relative"
|
class="md:w-[20vw] w-[35vw] aspect-334/440 bg-linear-to-t from-ieee-blue-100/5 to-ieee-blue-100/25 rounded-[10%] flex flex-col items-center relative"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={picture}
|
src={picture}
|
||||||
|
|
|
@ -25,7 +25,7 @@ const currentFilter = "All";
|
||||||
<Image
|
<Image
|
||||||
src={neko}
|
src={neko}
|
||||||
alt="About image"
|
alt="About image"
|
||||||
class="absolute top-[10%] left-[16%] aspect-[399/491] object-cover w-[27vw] md:w-[14vw] rounded-[2vw]"
|
class="absolute top-[10%] left-[16%] aspect-399/491 object-cover w-[27vw] md:w-[14vw] rounded-[2vw]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ const { title, text } = Astro.props;
|
||||||
<div class="flex items-center text-[4.5vw] md:text-[2.5vw] mb-[3%]">
|
<div class="flex items-center text-[4.5vw] md:text-[2.5vw] mb-[3%]">
|
||||||
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
|
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
|
||||||
<p
|
<p
|
||||||
class="text-transparent bg-clip-text bg-gradient-to-b from-white via-white to-ieee-black"
|
class="text-transparent bg-clip-text bg-linear-to-b from-white via-white to-ieee-black"
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -66,7 +66,7 @@ import pages from "../../data/pages.json";
|
||||||
<!-- Mobile Hamburger/Close Button -->
|
<!-- Mobile Hamburger/Close Button -->
|
||||||
<button
|
<button
|
||||||
id="menu-btn"
|
id="menu-btn"
|
||||||
class="md:hidden text-white p-2 flex justify-center items-center focus:outline-none relative z-[60] scale-150"
|
class="md:hidden text-white p-2 flex justify-center items-center focus:outline-hidden relative z-60 scale-150"
|
||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
>
|
>
|
||||||
<!-- Hamburger Icon -->
|
<!-- Hamburger Icon -->
|
||||||
|
@ -101,7 +101,7 @@ import pages from "../../data/pages.json";
|
||||||
<!-- Mobile Menu -->
|
<!-- Mobile Menu -->
|
||||||
<div
|
<div
|
||||||
id="mobile-menu"
|
id="mobile-menu"
|
||||||
class="fixed inset-0 z-[51] hidden xl:hidden motion-safe:transition-transform motion-safe:duration-300 translate-x-full bg-black"
|
class="fixed inset-0 z-51 hidden xl:hidden motion-safe:transition-transform motion-safe:duration-300 translate-x-full bg-black"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center min-h-screen w-full justify-center py-20 px-4 space-y-6 overflow-y-auto"
|
class="flex flex-col items-center min-h-screen w-full justify-center py-20 px-4 space-y-6 overflow-y-auto"
|
||||||
|
@ -167,9 +167,7 @@ import pages from "../../data/pages.json";
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#mobile-menu.show {
|
|
||||||
@apply translate-x-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-dropdown-content {
|
.mobile-dropdown-content {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
|
@ -22,7 +22,7 @@ import Link from "next/link";
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="md:w-[85%] w-full rounded-[3vw] bg-gradient-to-r from-ieee-blue-300 to-ieee-blue-100 relative md:h-[15vw] h-[30vw] flex items-center text-white/90"
|
class="md:w-[85%] w-full rounded-[3vw] bg-linear-to-r from-ieee-blue-300 to-ieee-blue-100 relative md:h-[15vw] h-[30vw] flex items-center text-white/90"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-inview
|
data-inview
|
||||||
|
|
|
@ -225,7 +225,7 @@ const EventLoad = () => {
|
||||||
{/* Event Header */}
|
{/* Event Header */}
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex justify-between items-start mb-2">
|
||||||
<h3 className="card-title text-base sm:text-lg font-semibold line-clamp-2">{event.event_name}</h3>
|
<h3 className="card-title text-base sm:text-lg font-semibold line-clamp-2">{event.event_name}</h3>
|
||||||
<div className="badge badge-primary badge-sm flex-shrink-0">{event.points_to_reward} pts</div>
|
<div className="badge badge-primary badge-sm shrink-0">{event.points_to_reward} pts</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Event Description */}
|
{/* Event Description */}
|
||||||
|
|
|
@ -144,8 +144,8 @@ export default function LeaderboardStats() {
|
||||||
<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-gray-100/50 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-gray-200 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-sm mb-2 mt-4 mx-4"></div>
|
||||||
<div className="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded mx-4"></div>
|
<div className="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded-sm mx-4"></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -154,25 +154,25 @@ 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-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-xs border border-gray-100 dark:border-gray-700">
|
||||||
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Members</div>
|
<div className="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-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-xs border border-gray-100 dark:border-gray-700">
|
||||||
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Points</div>
|
<div className="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-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-xs border border-gray-100 dark:border-gray-700">
|
||||||
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Top Score</div>
|
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Top Score</div>
|
||||||
<div className="mt-2 text-3xl font-bold text-indigo-600 dark:text-indigo-400">{stats.topScore}</div>
|
<div className="mt-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-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-xs border border-gray-100 dark:border-gray-700">
|
||||||
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Your Score</div>
|
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Your Score</div>
|
||||||
<div className="mt-2 text-3xl font-bold text-indigo-600 dark:text-indigo-400">
|
<div className="mt-2 text-3xl font-bold text-indigo-600 dark:text-indigo-400">
|
||||||
{isAuthenticated ? stats.yourPoints : '-'}
|
{isAuthenticated ? stats.yourPoints : '-'}
|
||||||
|
|
|
@ -213,7 +213,7 @@ export default function LeaderboardTable() {
|
||||||
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-gray-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-white/90 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-hidden
|
||||||
focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
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)}
|
||||||
|
@ -260,7 +260,7 @@ export default function LeaderboardTable() {
|
||||||
</td>
|
</td>
|
||||||
<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="shrink-0 h-10 w-10">
|
||||||
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden relative">
|
<div className="w-10 h-10 rounded-full bg-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} />
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,89 +0,0 @@
|
||||||
---
|
|
||||||
import { Icon } from "astro-icon/components";
|
|
||||||
import EmailRequestSettings from "./SettingsSection/EmailRequestSettings";
|
|
||||||
|
|
||||||
// Import environment variables for debugging if needed
|
|
||||||
const logtoApiEndpoint = import.meta.env.LOGTO_API_ENDPOINT || "";
|
|
||||||
---
|
|
||||||
<div id="officer-email-section" class="">
|
|
||||||
<div class="mb-6">
|
|
||||||
<h2 class="text-2xl font-bold">IEEE Email Management</h2>
|
|
||||||
<p class="opacity-70">Manage your official IEEE UCSD email address</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- IEEE Email Management Card -->
|
|
||||||
<div
|
|
||||||
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
|
|
||||||
>
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="inline-flex items-center justify-center p-3 rounded-full bg-primary text-primary-foreground"
|
|
||||||
>
|
|
||||||
<Icon name="heroicons:envelope" class="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
IEEE Email Address
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm opacity-70 mb-4">
|
|
||||||
Request and manage your official IEEE UCSD email address. This email can be used for official IEEE communications and professional purposes.
|
|
||||||
</p>
|
|
||||||
<div class="h-px w-full bg-border my-4"></div>
|
|
||||||
<EmailRequestSettings client:load />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Email Guidelines Card -->
|
|
||||||
<div
|
|
||||||
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
|
|
||||||
>
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="inline-flex items-center justify-center p-3 rounded-full bg-info text-info-foreground"
|
|
||||||
>
|
|
||||||
<Icon name="heroicons:information-circle" class="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
Email Usage Guidelines
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-4 text-sm">
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<Icon name="heroicons:information-circle" class="h-4 w-4" />
|
|
||||||
<div>
|
|
||||||
<h4 class="font-bold">Officer Email Access</h4>
|
|
||||||
<p>IEEE email addresses are only available to active IEEE UCSD officers. Your officer status is automatically verified when you request an email.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<h4 class="font-semibold">Acceptable Use:</h4>
|
|
||||||
<ul class="list-disc list-inside space-y-1 opacity-80">
|
|
||||||
<li>Official IEEE UCSD communications</li>
|
|
||||||
<li>Professional networking related to IEEE activities</li>
|
|
||||||
<li>Event coordination and planning</li>
|
|
||||||
<li>Communications with sponsors and external partners</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<h4 class="font-semibold">Email Features:</h4>
|
|
||||||
<ul class="list-disc list-inside space-y-1 opacity-80">
|
|
||||||
<li>Webmail access at <a href="https://mail.ieeeucsd.org" target="_blank" rel="noopener noreferrer" class="link link-primary">https://mail.ieeeucsd.org</a></li>
|
|
||||||
<li>IMAP/SMTP support for email clients</li>
|
|
||||||
<li>5GB storage space</li>
|
|
||||||
<li>Professional @ieeeucsd.org domain</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<h4 class="font-semibold">Important Notes:</h4>
|
|
||||||
<ul class="list-disc list-inside space-y-1 opacity-80">
|
|
||||||
<li>Your email username is based on your personal email address</li>
|
|
||||||
<li>Passwords can be reset through this interface</li>
|
|
||||||
<li>Email access may be revoked when officer status changes</li>
|
|
||||||
<li>Contact the webmaster for any technical issues</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -218,7 +218,7 @@ const currentPage = eventResponse.page;
|
||||||
</div>
|
</div>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-4 w-4 flex-shrink-0 ml-2"
|
class="h-4 w-4 shrink-0 ml-2"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
|
@ -230,7 +230,7 @@ const currentPage = eventResponse.page;
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"
|
class="dropdown-content z-1 menu p-2 shadow-sm bg-base-100 rounded-box w-52"
|
||||||
>
|
>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label cursor-pointer">
|
<label class="label cursor-pointer">
|
||||||
|
@ -266,7 +266,7 @@ const currentPage = eventResponse.page;
|
||||||
</div>
|
</div>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-4 w-4 flex-shrink-0 ml-2"
|
class="h-4 w-4 shrink-0 ml-2"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
|
@ -279,7 +279,7 @@ const currentPage = eventResponse.page;
|
||||||
<div
|
<div
|
||||||
id="quarterDropdownContent"
|
id="quarterDropdownContent"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"
|
class="dropdown-content z-1 menu p-2 shadow-sm bg-base-100 rounded-box w-52"
|
||||||
>
|
>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label cursor-pointer">
|
<label class="label cursor-pointer">
|
||||||
|
@ -346,7 +346,7 @@ const currentPage = eventResponse.page;
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"
|
class="dropdown-content z-1 menu p-2 shadow-sm bg-base-100 rounded-box w-52"
|
||||||
>
|
>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label cursor-pointer">
|
<label class="label cursor-pointer">
|
||||||
|
@ -412,7 +412,7 @@ const currentPage = eventResponse.page;
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"
|
class="dropdown-content z-1 menu p-2 shadow-sm bg-base-100 rounded-box w-52"
|
||||||
>
|
>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label cursor-pointer">
|
<label class="label cursor-pointer">
|
||||||
|
@ -478,7 +478,7 @@ const currentPage = eventResponse.page;
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"
|
class="dropdown-content z-1 menu p-2 shadow-sm bg-base-100 rounded-box w-52"
|
||||||
>
|
>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label cursor-pointer">
|
<label class="label cursor-pointer">
|
||||||
|
@ -1745,7 +1745,7 @@ const currentPage = eventResponse.page;
|
||||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
<span class="truncate">${filename}</span>
|
<span class="truncate">${filename}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 flex-shrink-0">
|
<div class="flex gap-2 shrink-0">
|
||||||
<button type="button" class="btn btn-ghost btn-xs" onclick='window.showFilePreviewOfficer(${previewData})'>
|
<button type="button" class="btn btn-ghost btn-xs" onclick='window.showFilePreviewOfficer(${previewData})'>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/>
|
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/>
|
||||||
|
|
|
@ -50,7 +50,7 @@ const HighlightText = ({ text, searchTerms }: { text: string | number | null | u
|
||||||
part.toLowerCase().includes(term.toLowerCase())
|
part.toLowerCase().includes(term.toLowerCase())
|
||||||
);
|
);
|
||||||
return isMatch ? (
|
return isMatch ? (
|
||||||
<mark key={i} className="bg-primary/20 rounded px-1">{part}</mark>
|
<mark key={i} className="bg-primary/20 rounded-sm px-1">{part}</mark>
|
||||||
) : (
|
) : (
|
||||||
<span key={i}>{part}</span>
|
<span key={i}>{part}</span>
|
||||||
);
|
);
|
||||||
|
|
|
@ -86,26 +86,11 @@ 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[];
|
||||||
// Combine existing files with new files instead of replacing
|
setInvoiceFiles(newFiles);
|
||||||
const combinedFiles = [...invoiceFiles, ...newFiles];
|
onDataChange({ invoice_files: 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);
|
||||||
|
@ -249,10 +234,8 @@ 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[];
|
||||||
// Combine existing files with new files instead of replacing
|
setInvoiceFiles(newFiles);
|
||||||
const combinedFiles = [...invoiceFiles, ...newFiles];
|
onDataChange({ invoice_files: newFiles });
|
||||||
setInvoiceFiles(combinedFiles);
|
|
||||||
onDataChange({ invoice_files: combinedFiles });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -275,7 +258,7 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
>
|
>
|
||||||
<motion.div variants={itemVariants}>
|
<motion.div variants={itemVariants}>
|
||||||
<h2 className="text-3xl font-bold mb-4 text-primary bg-gradient-to-r from-primary to-primary-focus bg-clip-text text-transparent">
|
<h2 className="text-3xl font-bold mb-4 text-primary bg-linear-to-r from-primary to-primary-focus bg-clip-text text-transparent">
|
||||||
AS Funding Details
|
AS Funding Details
|
||||||
</h2>
|
</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
@ -293,7 +276,7 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
|
||||||
{/* Invoice Upload Section */}
|
{/* Invoice Upload Section */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<h3 className="text-xl font-semibold mb-2 text-primary">Invoice Information</h3>
|
<h3 className="text-xl font-semibold mb-2 text-primary">Invoice Information</h3>
|
||||||
|
@ -328,44 +311,20 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
|
||||||
|
|
||||||
{invoiceFiles.length > 0 ? (
|
{invoiceFiles.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<p className="font-medium text-primary">{invoiceFiles.length} file(s) selected:</p>
|
<p className="font-medium text-primary">{invoiceFiles.length} file(s) selected:</p>
|
||||||
<button
|
<div className="max-h-24 overflow-y-auto text-left w-full">
|
||||||
type="button"
|
<ul className="list-disc list-inside text-sm">
|
||||||
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">
|
|
||||||
{invoiceFiles.map((file, index) => (
|
{invoiceFiles.map((file, index) => (
|
||||||
<div key={index} className="flex items-center justify-between bg-base-100 p-2 rounded">
|
<li key={index} className="truncate">{file.name}</li>
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">Click or drag to add more files</p>
|
<p className="text-xs text-gray-500">Click or drag to replace</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="font-medium">Drop your invoice files here or click to browse</p>
|
<p className="font-medium">Drop your invoice files here or click to browse</p>
|
||||||
<p className="text-xs text-gray-500">Supports PDF, JPG, JPEG, PNG (multiple files allowed)</p>
|
<p className="text-xs text-gray-500">Supports PDF, JPG, JPEG, PNG</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -375,7 +334,7 @@ const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataCha
|
||||||
{/* JSON/Builder Toggle */}
|
{/* JSON/Builder Toggle */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
|
|
@ -52,7 +52,7 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
>
|
>
|
||||||
<motion.div variants={itemVariants}>
|
<motion.div variants={itemVariants}>
|
||||||
<h2 className="text-3xl font-bold mb-4 text-primary bg-gradient-to-r from-primary to-primary-focus bg-clip-text text-transparent">
|
<h2 className="text-3xl font-bold mb-4 text-primary bg-linear-to-r from-primary to-primary-focus bg-clip-text text-transparent">
|
||||||
Event Details
|
Event Details
|
||||||
</h2>
|
</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
@ -70,7 +70,7 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
|
||||||
{/* Event Name */}
|
{/* Event Name */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
@ -92,7 +92,7 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
|
||||||
{/* Event Description */}
|
{/* Event Description */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
@ -118,7 +118,7 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
|
||||||
>
|
>
|
||||||
{/* Event Start Date */}
|
{/* Event Start Date */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
@ -129,27 +129,7 @@ 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) => {
|
onChange={(e) => onDataChange({ start_date_time: e.target.value })}
|
||||||
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}
|
||||||
|
@ -164,7 +144,7 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
|
||||||
|
|
||||||
{/* Event End Time */}
|
{/* Event End Time */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
@ -175,59 +155,25 @@ 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 ? (() => {
|
value={formData.end_date_time ? new Date(formData.end_date_time).toTimeString().substring(0, 5) : ''}
|
||||||
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) => {
|
||||||
const timeValue = e.target.value;
|
if (formData.start_date_time) {
|
||||||
if (timeValue && formData.start_date_time) {
|
|
||||||
try {
|
|
||||||
// Create a new date object from start_date_time
|
// Create a new date object from start_date_time
|
||||||
const startDate = new Date(formData.start_date_time);
|
const startDate = new Date(formData.start_date_time);
|
||||||
if (isNaN(startDate.getTime())) {
|
|
||||||
console.error('Invalid start date time');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the time value
|
// Parse the time value
|
||||||
const [hours, minutes] = timeValue.split(':').map(Number);
|
const [hours, minutes] = e.target.value.split(':').map(Number);
|
||||||
|
// Set the hours and minutes on the date
|
||||||
// Validate hours and minutes
|
startDate.setHours(hours, minutes);
|
||||||
if (isNaN(hours) || isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
|
||||||
console.error('Invalid time values');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new date with the same date as start but different time
|
|
||||||
const endDate = new Date(startDate);
|
|
||||||
endDate.setHours(hours, minutes, 0, 0);
|
|
||||||
|
|
||||||
// Update end_date_time with the new time but same date as start
|
// Update end_date_time with the new time but same date as start
|
||||||
onDataChange({ end_date_time: endDate.toISOString() });
|
onDataChange({ end_date_time: startDate.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">
|
||||||
{!formData.start_date_time
|
The end time will use the same date as the start date.
|
||||||
? "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>
|
||||||
|
@ -236,7 +182,7 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
|
||||||
{/* Event Location */}
|
{/* Event Location */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
@ -258,7 +204,7 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
|
||||||
{/* Room Booking */}
|
{/* Room Booking */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
|
|
@ -7,7 +7,6 @@ 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';
|
||||||
|
@ -70,13 +69,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 - MULTIPLE FILES
|
other_logos: File[]; // Form uses File objects, schema uses strings
|
||||||
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_files: File[]; // CHANGED: Multiple room booking files instead of single
|
room_booking: File | null;
|
||||||
invoice: File | null;
|
invoice: File | null;
|
||||||
invoice_files: File[]; // MULTIPLE FILES
|
invoice_files: File[];
|
||||||
invoiceData: InvoiceData;
|
invoiceData: InvoiceData;
|
||||||
needs_graphics?: boolean | null;
|
needs_graphics?: boolean | null;
|
||||||
needs_as_funding?: boolean | null;
|
needs_as_funding?: boolean | null;
|
||||||
|
@ -89,6 +88,7 @@ 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_files: [],
|
room_booking: null,
|
||||||
as_funding_required: false,
|
as_funding_required: false,
|
||||||
food_drinks_being_served: false,
|
food_drinks_being_served: false,
|
||||||
itemized_invoice: '',
|
itemized_invoice: '',
|
||||||
|
@ -134,10 +134,9 @@ const EventRequestForm: React.FC = () => {
|
||||||
const dataToStore = {
|
const dataToStore = {
|
||||||
...formDataToSave,
|
...formDataToSave,
|
||||||
other_logos: [],
|
other_logos: [],
|
||||||
room_booking_files: [],
|
room_booking: null,
|
||||||
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));
|
||||||
|
@ -154,27 +153,12 @@ const EventRequestForm: React.FC = () => {
|
||||||
if (savedData) {
|
if (savedData) {
|
||||||
try {
|
try {
|
||||||
const parsedData = JSON.parse(savedData);
|
const parsedData = JSON.parse(savedData);
|
||||||
|
|
||||||
// Check if the saved data is stale (older than 24 hours)
|
|
||||||
const now = Date.now();
|
|
||||||
const savedTime = parsedData.savedAt || 0;
|
|
||||||
const staleThreshold = 24 * 60 * 60 * 1000; // 24 hours
|
|
||||||
|
|
||||||
if (now - savedTime > staleThreshold) {
|
|
||||||
// Clear stale data
|
|
||||||
localStorage.removeItem('eventRequestFormData');
|
|
||||||
console.log('Cleared stale form data from localStorage');
|
|
||||||
} else {
|
|
||||||
// Load the saved data
|
|
||||||
setFormData(prevData => ({
|
setFormData(prevData => ({
|
||||||
...prevData,
|
...prevData,
|
||||||
...parsedData
|
...parsedData
|
||||||
}));
|
}));
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing saved form data:', e);
|
console.error('Error parsing saved form data:', e);
|
||||||
// Clear corrupted data
|
|
||||||
localStorage.removeItem('eventRequestFormData');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
@ -192,29 +176,9 @@ const EventRequestForm: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData(prevData => {
|
setFormData(prevData => {
|
||||||
const updatedData = { ...prevData, ...sectionData };
|
|
||||||
|
|
||||||
// Save to localStorage
|
// Save to localStorage
|
||||||
try {
|
const updatedData = { ...prevData, ...sectionData };
|
||||||
const dataToStore = {
|
localStorage.setItem('eventRequestFormData', JSON.stringify(updatedData));
|
||||||
...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;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -238,7 +202,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_files: [],
|
room_booking: null, // No room booking by default
|
||||||
as_funding_required: false,
|
as_funding_required: false,
|
||||||
food_drinks_being_served: false,
|
food_drinks_being_served: false,
|
||||||
itemized_invoice: '',
|
itemized_invoice: '',
|
||||||
|
@ -272,6 +236,7 @@ const EventRequestForm: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = Authentication.getInstance();
|
const auth = Authentication.getInstance();
|
||||||
|
@ -302,36 +267,8 @@ 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: (() => {
|
start_date_time: new Date(formData.start_date_time).toISOString(),
|
||||||
try {
|
end_date_time: formData.end_date_time ? new Date(formData.end_date_time).toISOString() : new Date(formData.start_date_time).toISOString(),
|
||||||
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,
|
||||||
|
@ -340,14 +277,7 @@ 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 ? (() => {
|
flyer_advertising_start_date: formData.flyer_advertising_start_date ? new Date(formData.flyer_advertising_start_date).toISOString() : '',
|
||||||
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,
|
||||||
|
@ -372,126 +302,36 @@ 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 with deletion detection
|
// Force sync the event requests collection to update IndexedDB
|
||||||
await dataSync.syncCollection(Collections.EVENT_REQUESTS, "", "-created", {}, true);
|
await dataSync.syncCollection(Collections.EVENT_REQUESTS);
|
||||||
|
|
||||||
console.log('Event request record created:', record.id);
|
// Upload files if they exist
|
||||||
|
|
||||||
// Upload files if they exist - handle each file type separately
|
|
||||||
const fileUploadErrors: string[] = [];
|
|
||||||
|
|
||||||
// Upload other logos
|
|
||||||
if (formData.other_logos.length > 0) {
|
if (formData.other_logos.length > 0) {
|
||||||
try {
|
|
||||||
console.log('Uploading other logos:', formData.other_logos.length, 'files');
|
|
||||||
console.log('Other logos files:', formData.other_logos.map(f => ({ name: f.name, size: f.size, type: f.type })));
|
|
||||||
await fileManager.uploadFiles('event_request', record.id, 'other_logos', formData.other_logos);
|
await fileManager.uploadFiles('event_request', record.id, 'other_logos', formData.other_logos);
|
||||||
console.log('Other logos uploaded successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to upload other logos:', error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
fileUploadErrors.push(`Failed to upload custom logo files: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload room booking files
|
if (formData.room_booking) {
|
||||||
if (formData.room_booking_files && formData.room_booking_files.length > 0) {
|
await fileManager.uploadFile('event_request', record.id, 'room_booking', formData.room_booking);
|
||||||
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 invoice files
|
// Upload multiple invoice files
|
||||||
if (formData.invoice_files && formData.invoice_files.length > 0) {
|
if (formData.invoice_files && formData.invoice_files.length > 0) {
|
||||||
try {
|
await fileManager.appendFiles('event_request', record.id, 'invoice_files', formData.invoice_files);
|
||||||
console.log('Uploading invoice files:', formData.invoice_files.length, 'files');
|
|
||||||
console.log('Invoice files:', formData.invoice_files.map(f => ({ name: f.name, size: f.size, type: f.type })));
|
|
||||||
console.log('Using collection:', 'event_request', 'record ID:', record.id, 'field:', 'invoice');
|
|
||||||
|
|
||||||
// Use the correct field name 'invoice' instead of 'invoice_files'
|
// For backward compatibility, also upload the first file as the main invoice
|
||||||
await fileManager.uploadFiles('event_request', record.id, 'invoice', formData.invoice_files);
|
if (formData.invoice || formData.invoice_files[0]) {
|
||||||
console.log('Invoice files uploaded successfully');
|
const mainInvoice = formData.invoice || formData.invoice_files[0];
|
||||||
} catch (error) {
|
await fileManager.uploadFile('event_request', record.id, 'invoice', mainInvoice);
|
||||||
console.error('Failed to upload invoice files:', error);
|
|
||||||
console.error('Error details:', {
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
collection: 'event_request',
|
|
||||||
recordId: record.id,
|
|
||||||
field: 'invoice',
|
|
||||||
fileCount: formData.invoice_files.length
|
|
||||||
});
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
fileUploadErrors.push(`Failed to upload invoice files: ${errorMessage}`);
|
|
||||||
}
|
}
|
||||||
} else if (formData.invoice) {
|
} else if (formData.invoice) {
|
||||||
try {
|
|
||||||
console.log('Uploading single invoice file:', { name: formData.invoice.name, size: formData.invoice.size, type: formData.invoice.type });
|
|
||||||
await fileManager.uploadFile('event_request', record.id, 'invoice', formData.invoice);
|
await fileManager.uploadFile('event_request', record.id, 'invoice', formData.invoice);
|
||||||
console.log('Invoice file uploaded successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to upload invoice file:', error);
|
|
||||||
console.error('Error details:', {
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
collection: 'event_request',
|
|
||||||
recordId: record.id,
|
|
||||||
field: 'invoice'
|
|
||||||
});
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
fileUploadErrors.push(`Failed to upload invoice file: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show file upload warnings if any occurred
|
|
||||||
if (fileUploadErrors.length > 0) {
|
|
||||||
console.warn('File upload errors:', fileUploadErrors);
|
|
||||||
// Show each file upload error as a separate toast for better UX
|
|
||||||
fileUploadErrors.forEach(error => {
|
|
||||||
toast.error(error, {
|
|
||||||
duration: 6000, // Longer duration for file upload errors
|
|
||||||
position: 'top-right'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Also show a summary toast
|
|
||||||
toast.error(`Event request submitted successfully, but ${fileUploadErrors.length} file upload(s) failed. Please check the errors above and re-upload the files manually.`, {
|
|
||||||
duration: 8000,
|
|
||||||
position: 'top-center'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Keep success toast for form submission since it's a user action
|
|
||||||
toast.success('Event request submitted successfully!');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear form data from localStorage
|
// Clear form data from localStorage
|
||||||
localStorage.removeItem('eventRequestFormData');
|
localStorage.removeItem('eventRequestFormData');
|
||||||
|
|
||||||
// Send email notification to coordinators (non-blocking)
|
// Keep success toast for form submission since it's a user action
|
||||||
try {
|
toast.success('Event request submitted successfully!');
|
||||||
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();
|
||||||
|
@ -504,6 +344,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
@ -566,47 +407,11 @@ 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 || formData.end_date_time.trim() === '') {
|
if (!formData.end_date_time) {
|
||||||
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() === '') {
|
||||||
|
@ -614,14 +419,13 @@ const EventRequestForm: React.FC = () => {
|
||||||
valid = false;
|
valid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.will_or_have_room_booking === undefined || formData.will_or_have_room_booking === null) {
|
if (formData.will_or_have_room_booking === undefined) {
|
||||||
errors.push('Room booking status is required');
|
errors.push('Room booking status is required');
|
||||||
valid = false;
|
valid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
// Show the first error as a toast instead of setting error state
|
setError(errors[0]);
|
||||||
toast.error(errors[0]);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -641,9 +445,9 @@ const EventRequestForm: React.FC = () => {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// REQUIRED: Room booking files if will_or_have_room_booking is true
|
// Only require room booking file if will_or_have_room_booking is true
|
||||||
if (formData.will_or_have_room_booking && formData.room_booking_files.length === 0) {
|
if (formData.will_or_have_room_booking && !formData.room_booking) {
|
||||||
toast.error('Room booking files are required when you need a room booking');
|
toast.error('Please upload your room booking confirmation');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -663,16 +467,10 @@ const EventRequestForm: React.FC = () => {
|
||||||
|
|
||||||
// Validate AS Funding Section
|
// Validate AS Funding Section
|
||||||
const validateASFundingSection = () => {
|
const validateASFundingSection = () => {
|
||||||
if (formData.as_funding_required || formData.needs_as_funding) {
|
if (formData.as_funding_required) {
|
||||||
// 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) {
|
||||||
toast.error('Please add at least one item to your invoice');
|
setError('Please add at least one item to your invoice');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -684,7 +482,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) {
|
||||||
toast.error(`Your budget ($${totalBudget.toFixed(2)}) exceeds the maximum allowed ($${maxBudget}). The absolute maximum is $5,000.`);
|
setError(`Your budget (${totalBudget.toFixed(2)} dollars) exceeds the maximum allowed (${maxBudget} dollars). The absolute maximum is $5,000.`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1015,6 +813,21 @@ 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">
|
||||||
|
|
|
@ -317,7 +317,7 @@ export const EventRequestFormPreviewModal = ({ formData, closeModal }: EventRequ
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="event-request-preview-modal-overlay"
|
id="event-request-preview-modal-overlay"
|
||||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-[999999] overflow-y-auto p-4"
|
className="fixed inset-0 bg-black/60 backdrop-blur-xs flex items-center justify-center z-999999 overflow-y-auto p-4"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
closeModal();
|
closeModal();
|
||||||
|
@ -530,9 +530,9 @@ const EventRequestFormPreview: React.FC<EventRequestFormPreviewProps> = ({
|
||||||
{/* Event Details Section */}
|
{/* Event Details Section */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={sectionVariants}
|
variants={sectionVariants}
|
||||||
className="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden"
|
className="bg-base-100 rounded-xl border border-base-300 shadow-xs overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="bg-gradient-to-r from-primary/10 to-primary/5 p-4 flex items-center">
|
<div className="bg-linear-to-r from-primary/10 to-primary/5 p-4 flex items-center">
|
||||||
<Icon icon="heroicons:calendar" className="text-primary w-5 h-5 mr-2" />
|
<Icon icon="heroicons:calendar" className="text-primary w-5 h-5 mr-2" />
|
||||||
<h3 className="text-lg font-semibold text-base-content">
|
<h3 className="text-lg font-semibold text-base-content">
|
||||||
Event Details
|
Event Details
|
||||||
|
@ -599,9 +599,9 @@ const EventRequestFormPreview: React.FC<EventRequestFormPreviewProps> = ({
|
||||||
{formData.flyers_needed && (
|
{formData.flyers_needed && (
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={sectionVariants}
|
variants={sectionVariants}
|
||||||
className="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden"
|
className="bg-base-100 rounded-xl border border-base-300 shadow-xs overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="bg-gradient-to-r from-primary/10 to-primary/5 p-4 flex items-center">
|
<div className="bg-linear-to-r from-primary/10 to-primary/5 p-4 flex items-center">
|
||||||
<Icon icon="heroicons:document-duplicate" className="text-primary w-5 h-5 mr-2" />
|
<Icon icon="heroicons:document-duplicate" className="text-primary w-5 h-5 mr-2" />
|
||||||
<h3 className="text-lg font-semibold text-base-content">
|
<h3 className="text-lg font-semibold text-base-content">
|
||||||
PR Materials
|
PR Materials
|
||||||
|
@ -688,9 +688,9 @@ const EventRequestFormPreview: React.FC<EventRequestFormPreviewProps> = ({
|
||||||
{/* TAP Form Section */}
|
{/* TAP Form Section */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={sectionVariants}
|
variants={sectionVariants}
|
||||||
className="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden"
|
className="bg-base-100 rounded-xl border border-base-300 shadow-xs overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="bg-gradient-to-r from-accent/10 to-accent/5 p-4 flex items-center">
|
<div className="bg-linear-to-r from-accent/10 to-accent/5 p-4 flex items-center">
|
||||||
<Icon icon="heroicons:building-office-2" className="text-accent w-5 h-5 mr-2" />
|
<Icon icon="heroicons:building-office-2" className="text-accent w-5 h-5 mr-2" />
|
||||||
<h3 className="text-lg font-semibold text-base-content">
|
<h3 className="text-lg font-semibold text-base-content">
|
||||||
TAP Information
|
TAP Information
|
||||||
|
@ -732,9 +732,9 @@ const EventRequestFormPreview: React.FC<EventRequestFormPreviewProps> = ({
|
||||||
{formData.needs_as_funding && (
|
{formData.needs_as_funding && (
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={sectionVariants}
|
variants={sectionVariants}
|
||||||
className="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden"
|
className="bg-base-100 rounded-xl border border-base-300 shadow-xs overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="bg-gradient-to-r from-primary/10 to-primary/5 p-4 flex items-center">
|
<div className="bg-linear-to-r from-primary/10 to-primary/5 p-4 flex items-center">
|
||||||
<Icon icon="heroicons:currency-dollar" className="text-primary w-5 h-5 mr-2" />
|
<Icon icon="heroicons:currency-dollar" className="text-primary w-5 h-5 mr-2" />
|
||||||
<h3 className="text-lg font-semibold text-base-content">
|
<h3 className="text-lg font-semibold text-base-content">
|
||||||
AS Funding
|
AS Funding
|
||||||
|
|
|
@ -388,7 +388,7 @@ const InvoiceBuilder: React.FC<InvoiceBuilderProps> = ({ invoiceData, onChange }
|
||||||
{/* Vendor Input */}
|
{/* Vendor Input */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
@ -418,7 +418,7 @@ const InvoiceBuilder: React.FC<InvoiceBuilderProps> = ({ invoiceData, onChange }
|
||||||
{/* Add New Item Form */}
|
{/* Add New Item Form */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<h4 className="font-medium text-lg mb-4">Add New Item</h4>
|
<h4 className="font-medium text-lg mb-4">Add New Item</h4>
|
||||||
|
@ -515,7 +515,7 @@ const InvoiceBuilder: React.FC<InvoiceBuilderProps> = ({ invoiceData, onChange }
|
||||||
{/* Items Section */}
|
{/* Items Section */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
@ -622,7 +622,7 @@ const InvoiceBuilder: React.FC<InvoiceBuilderProps> = ({ invoiceData, onChange }
|
||||||
{/* Tax and Tip Section */}
|
{/* Tax and Tip Section */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label-text font-medium text-lg mb-4">Tax and Tip</label>
|
<label className="label-text font-medium text-lg mb-4">Tax and Tip</label>
|
||||||
|
@ -683,7 +683,7 @@ const InvoiceBuilder: React.FC<InvoiceBuilderProps> = ({ invoiceData, onChange }
|
||||||
{/* Totals Section */}
|
{/* Totals Section */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-medium mb-4">Invoice Summary</h3>
|
<h3 className="text-lg font-medium mb-4">Invoice Summary</h3>
|
||||||
|
|
|
@ -4,7 +4,6 @@ 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 = {
|
||||||
|
@ -123,26 +122,11 @@ 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[];
|
||||||
// Combine existing files with new files instead of replacing
|
setOtherLogoFiles(newFiles);
|
||||||
const combinedFiles = [...otherLogoFiles, ...newFiles];
|
onDataChange({ other_logos: 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();
|
||||||
|
@ -160,10 +144,8 @@ 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[];
|
||||||
// Combine existing files with new files instead of replacing
|
setOtherLogoFiles(newFiles);
|
||||||
const combinedFiles = [...otherLogoFiles, ...newFiles];
|
onDataChange({ other_logos: newFiles });
|
||||||
setOtherLogoFiles(combinedFiles);
|
|
||||||
onDataChange({ other_logos: combinedFiles });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -175,7 +157,7 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
>
|
>
|
||||||
<motion.div variants={itemVariants}>
|
<motion.div variants={itemVariants}>
|
||||||
<h2 className="text-3xl font-bold mb-4 text-primary bg-gradient-to-r from-primary to-primary-focus bg-clip-text text-transparent">
|
<h2 className="text-3xl font-bold mb-4 text-primary bg-linear-to-r from-primary to-primary-focus bg-clip-text text-transparent">
|
||||||
PR Materials
|
PR Materials
|
||||||
</h2>
|
</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
@ -193,7 +175,7 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
{/* Type of material needed */}
|
{/* Type of material needed */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
@ -257,7 +239,7 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
) && (
|
) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
|
@ -284,7 +266,7 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
{/* Logos Required */}
|
{/* Logos Required */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
@ -322,7 +304,7 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
{formData.required_logos.includes(LogoOptions.OTHER) && (
|
{formData.required_logos.includes(LogoOptions.OTHER) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
|
@ -367,44 +349,20 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
|
|
||||||
{otherLogoFiles.length > 0 ? (
|
{otherLogoFiles.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<p className="font-medium text-primary">{otherLogoFiles.length} file(s) selected:</p>
|
<p className="font-medium text-primary">{otherLogoFiles.length} file(s) selected:</p>
|
||||||
<button
|
<div className="max-h-24 overflow-y-auto text-left w-full">
|
||||||
type="button"
|
<ul className="list-disc list-inside text-sm">
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleClearAllLogoFiles();
|
|
||||||
}}
|
|
||||||
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">
|
|
||||||
{otherLogoFiles.map((file, index) => (
|
{otherLogoFiles.map((file, index) => (
|
||||||
<div key={index} className="flex items-center justify-between bg-base-100 p-2 rounded">
|
<li key={index} className="truncate">{file.name}</li>
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">Click or drag to add more files</p>
|
<p className="text-xs text-gray-500">Click or drag to replace</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="font-medium">Drop your logo files here or click to browse</p>
|
<p className="font-medium">Drop your logo files here or click to browse</p>
|
||||||
<p className="text-xs text-gray-500">Please upload transparent logo files (PNG preferred, multiple files allowed)</p>
|
<p className="text-xs text-gray-500">Please upload transparent logo files (PNG preferred)</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -415,7 +373,7 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
{/* Format */}
|
{/* Format */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
@ -442,7 +400,7 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
{/* Additional specifications */}
|
{/* Additional specifications */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
@ -464,7 +422,7 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
{/* Photography Needed */}
|
{/* Photography Needed */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
|
|
@ -4,7 +4,6 @@ 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 = {
|
||||||
|
@ -70,12 +69,11 @@ interface TAPFormSectionProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange }) => {
|
const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange }) => {
|
||||||
const [roomBookingFiles, setRoomBookingFiles] = useState<File[]>(formData.room_booking_files || []);
|
const [roomBookingFile, setRoomBookingFile] = useState<File | null>(formData.room_booking);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [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(() => {
|
||||||
|
@ -91,58 +89,27 @@ 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 newFiles = Array.from(e.target.files) as File[];
|
const file = e.target.files[0];
|
||||||
|
|
||||||
// Check file sizes - 1MB limit for each file
|
// Check file size - 1MB limit
|
||||||
const oversizedFiles = newFiles.filter(file => file.size > 1024 * 1024);
|
if (file.size > 1024 * 1024) {
|
||||||
if (oversizedFiles.length > 0) {
|
setFileError("Room booking file size must be under 1MB");
|
||||||
setFileError(`${oversizedFiles.length} file(s) exceed 1MB limit`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFileError(null);
|
setFileError(null);
|
||||||
// Combine existing files with new files instead of replacing
|
setRoomBookingFile(file);
|
||||||
const combinedFiles = [...roomBookingFiles, ...newFiles];
|
onDataChange({ room_booking: file });
|
||||||
setRoomBookingFiles(combinedFiles);
|
|
||||||
onDataChange({ room_booking_files: combinedFiles });
|
|
||||||
|
|
||||||
// Create preview URL for the first new file
|
// Create preview URL
|
||||||
if (filePreviewUrl) {
|
if (filePreviewUrl) {
|
||||||
URL.revokeObjectURL(filePreviewUrl);
|
URL.revokeObjectURL(filePreviewUrl);
|
||||||
}
|
}
|
||||||
const url = URL.createObjectURL(newFiles[0]);
|
const url = URL.createObjectURL(file);
|
||||||
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();
|
||||||
|
@ -159,28 +126,24 @@ 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 newFiles = Array.from(e.dataTransfer.files) as File[];
|
const file = e.dataTransfer.files[0];
|
||||||
|
|
||||||
// Check file sizes - 1MB limit for each file
|
// Check file size - 1MB limit
|
||||||
const oversizedFiles = newFiles.filter(file => file.size > 1024 * 1024);
|
if (file.size > 1024 * 1024) {
|
||||||
if (oversizedFiles.length > 0) {
|
setFileError("Room booking file size must be under 1MB");
|
||||||
setFileError(`${oversizedFiles.length} file(s) exceed 1MB limit`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFileError(null);
|
setFileError(null);
|
||||||
// Combine existing files with new files instead of replacing
|
setRoomBookingFile(file);
|
||||||
const combinedFiles = [...roomBookingFiles, ...newFiles];
|
onDataChange({ room_booking: file });
|
||||||
setRoomBookingFiles(combinedFiles);
|
|
||||||
onDataChange({ room_booking_files: combinedFiles });
|
|
||||||
|
|
||||||
// Create preview URL for the first new file
|
// Create preview URL
|
||||||
if (filePreviewUrl) {
|
if (filePreviewUrl) {
|
||||||
URL.revokeObjectURL(filePreviewUrl);
|
URL.revokeObjectURL(filePreviewUrl);
|
||||||
}
|
}
|
||||||
const url = URL.createObjectURL(newFiles[0]);
|
const url = URL.createObjectURL(file);
|
||||||
setFilePreviewUrl(url);
|
setFilePreviewUrl(url);
|
||||||
setSelectedPreviewFile(newFiles[0]);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -206,7 +169,7 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
>
|
>
|
||||||
<motion.div variants={itemVariants}>
|
<motion.div variants={itemVariants}>
|
||||||
<h2 className="text-3xl font-bold mb-4 text-primary bg-gradient-to-r from-primary to-primary-focus bg-clip-text text-transparent">
|
<h2 className="text-3xl font-bold mb-4 text-primary bg-linear-to-r from-primary to-primary-focus bg-clip-text text-transparent">
|
||||||
TAP Form Information
|
TAP Form Information
|
||||||
</h2>
|
</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
@ -224,7 +187,7 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
{/* Expected attendance */}
|
{/* Expected attendance */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
@ -247,7 +210,7 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
whileHover="hover"
|
whileHover="hover"
|
||||||
variants={inputHoverVariants}
|
variants={inputHoverVariants}
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-gray-400 bg-base-100 px-2 py-1 rounded">
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-gray-400 bg-base-100 px-2 py-1 rounded-sm">
|
||||||
people
|
people
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -292,18 +255,13 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
{/* Room booking confirmation - Show file error if present */}
|
{/* Room booking confirmation - Show file error if present */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<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">
|
||||||
|
@ -334,7 +292,6 @@ 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">
|
||||||
|
@ -347,46 +304,16 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
</svg>
|
</svg>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{roomBookingFiles.length > 0 ? (
|
{roomBookingFile ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between w-full">
|
<p className="font-medium text-primary">File selected:</p>
|
||||||
<p className="font-medium text-primary">{roomBookingFiles.length} file(s) selected:</p>
|
<p className="text-sm">{roomBookingFile.name}</p>
|
||||||
<button
|
<p className="text-xs text-gray-500">Click or drag to replace (Max size: 1MB)</p>
|
||||||
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 files here or click to browse</p>
|
<p className="font-medium">Drop your file here or click to browse</p>
|
||||||
<p className="text-xs text-gray-500">Accepted formats: PDF, PNG, JPG (Max size: 1MB each, multiple files allowed)</p>
|
<p className="text-xs text-gray-500">Accepted formats: PDF, PNG, JPG (Max size: 1MB)</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -402,20 +329,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 && roomBookingFiles.length > 0 && (
|
{formData.will_or_have_room_booking && roomBookingFile && (
|
||||||
<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 Files (${roomBookingFiles.length})`}
|
{showFilePreview ? 'Hide Preview' : 'Preview File'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File Preview Component */}
|
{/* File Preview Component */}
|
||||||
{showFilePreview && roomBookingFiles.length > 0 && (
|
{showFilePreview && filePreviewUrl && roomBookingFile && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
@ -423,7 +350,7 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
className="mt-4 p-4 bg-base-200 rounded-lg"
|
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 ({roomBookingFiles.length} files)</h3>
|
<h3 className="font-medium">File Preview</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-sm btn-circle"
|
className="btn btn-sm btn-circle"
|
||||||
|
@ -434,17 +361,7 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<FilePreview url={filePreviewUrl} filename={roomBookingFile.name} />
|
||||||
{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>
|
||||||
|
@ -452,7 +369,7 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
{/* Food/Drinks */}
|
{/* Food/Drinks */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
>
|
>
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
@ -504,7 +421,7 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
{formData.food_drinks_being_served && (
|
{formData.food_drinks_being_served && (
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="form-control bg-base-200/50 p-6 rounded-xl shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|
|
@ -195,7 +195,7 @@ const EventRequestModal: React.FC<{ isOpen: boolean, onClose: () => void, childr
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[99999]"
|
className="fixed inset-0 bg-black/60 backdrop-blur-xs z-99999"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: 0,
|
top: 0,
|
||||||
|
@ -258,14 +258,12 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use DataSyncService to get data from IndexedDB with forced sync and deletion detection
|
// Use DataSyncService to get data from IndexedDB with forced sync
|
||||||
const updatedRequests = await dataSync.getData<EventRequest>(
|
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);
|
||||||
|
@ -371,7 +369,7 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="bg-base-200 rounded-xl p-8 text-center shadow-sm"
|
className="bg-base-200 rounded-xl p-8 text-center shadow-xs"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center justify-center py-6">
|
<div className="flex flex-col items-center justify-center py-6">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
@ -453,7 +451,7 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'table' ? (
|
{viewMode === 'table' ? (
|
||||||
<div className="overflow-x-auto overflow-y-auto rounded-xl shadow-sm max-h-[70vh]">
|
<div className="overflow-x-auto overflow-y-auto rounded-xl shadow-xs max-h-[70vh]">
|
||||||
<table className="table table-zebra w-full text-xs">
|
<table className="table table-zebra w-full text-xs">
|
||||||
<thead className="bg-base-300/50 sticky top-0 z-10">
|
<thead className="bg-base-300/50 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -547,7 +545,7 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className={`card bg-base-200 shadow-sm hover:shadow-md transition-shadow border-l-4 ${getCardBorderClass(request.status)}`}
|
className={`card bg-base-200 shadow-xs hover:shadow-md transition-shadow border-l-4 ${getCardBorderClass(request.status)}`}
|
||||||
>
|
>
|
||||||
<div className="card-body p-5">
|
<div className="card-body p-5">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
|
@ -609,7 +607,7 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-base-300/30 p-5 rounded-xl text-sm shadow-sm">
|
<div className="bg-base-300/30 p-5 rounded-xl text-sm shadow-xs">
|
||||||
<h3 className="font-semibold mb-3 flex items-center gap-2">
|
<h3 className="font-semibold mb-3 flex items-center gap-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
|
|
@ -188,13 +188,13 @@ try {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="bg-gradient-to-br from-base-300/50 to-base-300/30 p-5 rounded-xl border border-base-300/50 shadow-inner text-sm text-gray-300 card-enter"
|
class="bg-linear-to-br from-base-300/50 to-base-300/30 p-5 rounded-xl border border-base-300/50 shadow-inner text-sm text-gray-300 card-enter"
|
||||||
style={`animation-delay: ${ANIMATION_DELAYS.info};`}
|
style={`animation-delay: ${ANIMATION_DELAYS.info};`}
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<Icon
|
<Icon
|
||||||
name="mdi:lightbulb-on"
|
name="mdi:lightbulb-on"
|
||||||
class="w-5 h-5 text-primary mt-1 flex-shrink-0"
|
class="w-5 h-5 text-primary mt-1 shrink-0"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium mb-2 text-white">
|
<p class="font-medium mb-2 text-white">
|
||||||
|
|
|
@ -29,10 +29,9 @@ interface ExtendedEventRequest extends SchemaEventRequest {
|
||||||
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
|
||||||
will_or_have_room_booking?: boolean;
|
will_or_have_room_booking?: boolean;
|
||||||
room_booking_files?: string[]; // CHANGED: Multiple room booking files instead of single
|
room_booking?: string; // Single file for room booking
|
||||||
room_reservation_needed?: boolean; // Keep for backward compatibility
|
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 +81,7 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
|
||||||
setFileUrl(secureUrl);
|
setFileUrl(secureUrl);
|
||||||
|
|
||||||
// Determine file type from extension
|
// Determine file type from extension
|
||||||
const extension = (typeof fileName === 'string' ? fileName.split('.').pop()?.toLowerCase() : '') || '';
|
const extension = fileName.split('.').pop()?.toLowerCase() || '';
|
||||||
setFileType(extension);
|
setFileType(extension);
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
@ -118,7 +117,7 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[300] flex items-center justify-center p-4 overflow-y-auto"
|
className="fixed inset-0 bg-black/70 backdrop-blur-xs z-300 flex items-center justify-center p-4 overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div className="bg-base-300 rounded-xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-hidden relative">
|
<div className="bg-base-300 rounded-xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-hidden relative">
|
||||||
<div className="p-4 flex justify-between items-center border-b border-base-200">
|
<div className="p-4 flex justify-between items-center border-b border-base-200">
|
||||||
|
@ -608,7 +607,7 @@ const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request })
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`file-${index}`}
|
key={`file-${index}`}
|
||||||
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer shadow-sm"
|
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer shadow-xs"
|
||||||
onClick={() => openFilePreview(fileId, displayName)}
|
onClick={() => openFilePreview(fileId, displayName)}
|
||||||
initial={{ opacity: 0, y: 5 }}
|
initial={{ opacity: 0, y: 5 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
@ -624,7 +623,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-1 min-w-0">
|
<div className="grow">
|
||||||
<p className="font-medium truncate" title={fileId}>
|
<p className="font-medium truncate" title={fileId}>
|
||||||
{displayName}
|
{displayName}
|
||||||
</p>
|
</p>
|
||||||
|
@ -642,7 +641,7 @@ const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request })
|
||||||
{request.invoice && (
|
{request.invoice && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="invoice"
|
key="invoice"
|
||||||
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer shadow-sm"
|
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer shadow-xs"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const invoiceFile = request.invoice || '';
|
const invoiceFile = request.invoice || '';
|
||||||
openFilePreview(invoiceFile, getFriendlyFileName(invoiceFile, 25));
|
openFilePreview(invoiceFile, getFriendlyFileName(invoiceFile, 25));
|
||||||
|
@ -661,7 +660,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="grow">
|
||||||
<p className="font-medium truncate" title={request.invoice}>
|
<p className="font-medium truncate" title={request.invoice}>
|
||||||
{getFriendlyFileName(request.invoice, 25)}
|
{getFriendlyFileName(request.invoice, 25)}
|
||||||
</p>
|
</p>
|
||||||
|
@ -1079,8 +1078,6 @@ 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) => {
|
||||||
|
@ -1099,33 +1096,8 @@ const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle flyers completed checkbox change
|
|
||||||
const handleFlyersCompletedChange = async (completed: boolean) => {
|
|
||||||
setIsUpdating(true);
|
|
||||||
try {
|
|
||||||
const { Update } = await import('../../../scripts/pocketbase/Update');
|
|
||||||
const update = Update.getInstance();
|
|
||||||
|
|
||||||
await update.updateField("event_request", request.id, "flyers_completed", completed);
|
|
||||||
|
|
||||||
setFlyersCompleted(completed);
|
|
||||||
toast.success(`Flyers completion status updated to ${completed ? 'completed' : 'not completed'}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update flyers completed status:', error);
|
|
||||||
toast.error('Failed to update flyers completion status');
|
|
||||||
} finally {
|
|
||||||
setIsUpdating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sync local state with request prop changes
|
|
||||||
useEffect(() => {
|
|
||||||
setFlyersCompleted(request.flyers_completed || false);
|
|
||||||
}, [request.flyers_completed]);
|
|
||||||
|
|
||||||
// Use the same utility functions as in the ASFundingTab
|
// 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() || '' : '';
|
||||||
};
|
};
|
||||||
|
@ -1140,7 +1112,6 @@ 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);
|
||||||
|
@ -1187,46 +1158,6 @@ 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"
|
||||||
|
@ -1338,7 +1269,7 @@ const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`logo-${index}`}
|
key={`logo-${index}`}
|
||||||
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer shadow-sm"
|
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer shadow-xs"
|
||||||
onClick={() => openFilePreview(logoId, displayName)}
|
onClick={() => openFilePreview(logoId, displayName)}
|
||||||
initial={{ opacity: 0, y: 5 }}
|
initial={{ opacity: 0, y: 5 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
@ -1391,7 +1322,7 @@ const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`flyer-file-${index}`}
|
key={`flyer-file-${index}`}
|
||||||
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer shadow-sm"
|
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer shadow-xs"
|
||||||
onClick={() => openFilePreview(fileId, displayName)}
|
onClick={() => openFilePreview(fileId, displayName)}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
@ -1429,7 +1360,7 @@ const PRMaterialsTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`general-file-${index}`}
|
key={`general-file-${index}`}
|
||||||
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer shadow-sm"
|
className="border rounded-lg overflow-hidden bg-base-300/30 hover:bg-base-300/50 transition-colors cursor-pointer shadow-xs"
|
||||||
onClick={() => openFilePreview(fileId, displayName)}
|
onClick={() => openFilePreview(fileId, displayName)}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
@ -1504,9 +1435,6 @@ 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",
|
||||||
|
@ -1537,14 +1465,8 @@ const EventRequestDetails = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusChange = async (newStatus: "submitted" | "pending" | "completed" | "declined") => {
|
const handleStatusChange = async (newStatus: "submitted" | "pending" | "completed" | "declined") => {
|
||||||
if (newStatus === 'declined') {
|
|
||||||
// Open decline reason modal instead of immediate confirmation
|
|
||||||
setDeclineReason('');
|
|
||||||
setIsDeclineModalOpen(true);
|
|
||||||
} else {
|
|
||||||
setNewStatus(newStatus);
|
setNewStatus(newStatus);
|
||||||
setIsConfirmModalOpen(true);
|
setIsConfirmModalOpen(true);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmStatusChange = async () => {
|
const confirmStatusChange = async () => {
|
||||||
|
@ -1570,72 +1492,6 @@ const EventRequestDetails = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle decline with reason
|
|
||||||
const handleDeclineWithReason = async () => {
|
|
||||||
if (!declineReason.trim()) {
|
|
||||||
toast.error('Please provide a reason for declining');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
// Use Update service to update both status and decline reason
|
|
||||||
const { Update } = await import('../../../scripts/pocketbase/Update');
|
|
||||||
const update = Update.getInstance();
|
|
||||||
|
|
||||||
await update.updateFields("event_request", request.id, {
|
|
||||||
status: 'declined',
|
|
||||||
declined_reason: declineReason
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send email notifications
|
|
||||||
const { EmailClient } = await import('../../../scripts/email/EmailClient');
|
|
||||||
const auth = Authentication.getInstance();
|
|
||||||
const changedByUserId = auth.getUserId();
|
|
||||||
|
|
||||||
await EmailClient.notifyEventRequestStatusChange(
|
|
||||||
request.id,
|
|
||||||
request.status,
|
|
||||||
'declined',
|
|
||||||
changedByUserId || undefined,
|
|
||||||
declineReason
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send design team notification if PR materials were needed
|
|
||||||
if (request.flyers_needed) {
|
|
||||||
await EmailClient.notifyDesignTeam(request.id, 'declined');
|
|
||||||
}
|
|
||||||
|
|
||||||
setAlertInfo({
|
|
||||||
show: true,
|
|
||||||
type: "success",
|
|
||||||
message: "Event request has been declined successfully."
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsDeclineModalOpen(false);
|
|
||||||
setDeclineReason('');
|
|
||||||
|
|
||||||
// Call the parent's onStatusChange if needed for UI updates
|
|
||||||
await onStatusChange(request.id, 'declined');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error declining request:', error);
|
|
||||||
setAlertInfo({
|
|
||||||
show: true,
|
|
||||||
type: "error",
|
|
||||||
message: "Failed to decline event request. Please try again."
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cancel decline action
|
|
||||||
const cancelDecline = () => {
|
|
||||||
setIsDeclineModalOpen(false);
|
|
||||||
setDeclineReason('');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-transparent w-full">
|
<div className="bg-transparent w-full">
|
||||||
{/* Tabs navigation */}
|
{/* Tabs navigation */}
|
||||||
|
@ -1721,7 +1577,7 @@ const EventRequestDetails = ({
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</label>
|
</label>
|
||||||
<ul tabIndex={0} className="dropdown-content z-[101] menu p-2 shadow bg-base-200 rounded-lg w-52">
|
<ul tabIndex={0} className="dropdown-content z-101 menu p-2 shadow-sm bg-base-200 rounded-lg w-52">
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
className={`flex items-center ${request.status === 'pending' ? 'bg-warning/20 text-warning' : ''}`}
|
className={`flex items-center ${request.status === 'pending' ? 'bg-warning/20 text-warning' : ''}`}
|
||||||
|
@ -1788,11 +1644,6 @@ 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>
|
||||||
|
|
||||||
|
@ -1838,20 +1689,17 @@ const EventRequestDetails = ({
|
||||||
<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_booking_files && request.room_booking_files.length > 0 ? 'badge-success' : 'badge-warning'}`}>
|
<div className={`badge ${request.room_booking ? 'badge-success' : 'badge-warning'}`}>
|
||||||
{request.room_booking_files && request.room_booking_files.length > 0 ? 'Booking Files Uploaded' : 'No Booking Files'}
|
{request.room_booking ? 'Booking File Uploaded' : 'No Booking File'}
|
||||||
</div>
|
</div>
|
||||||
{request.room_booking_files && request.room_booking_files.length > 0 && (
|
{request.room_booking && (
|
||||||
<div className="flex gap-2">
|
|
||||||
{request.room_booking_files.map((fileId, index) => (
|
|
||||||
<button
|
<button
|
||||||
key={index}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Dispatch event to update file preview modal
|
// Dispatch event to update file preview modal
|
||||||
const event = new CustomEvent('filePreviewStateChange', {
|
const event = new CustomEvent('filePreviewStateChange', {
|
||||||
detail: {
|
detail: {
|
||||||
url: `https://pocketbase.ieeeucsd.org/api/files/event_request/${request.id}/${fileId}`,
|
url: `https://pocketbase.ieeeucsd.org/api/files/event_request/${request.id}/${request.room_booking}`,
|
||||||
filename: fileId
|
filename: request.room_booking
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
|
@ -1860,12 +1708,10 @@ const EventRequestDetails = ({
|
||||||
const modal = document.getElementById('file-preview-modal') as HTMLDialogElement;
|
const modal = document.getElementById('file-preview-modal') as HTMLDialogElement;
|
||||||
if (modal) modal.showModal();
|
if (modal) modal.showModal();
|
||||||
}}
|
}}
|
||||||
className="btn btn-xs btn-primary"
|
className="btn btn-xs btn-primary ml-2"
|
||||||
>
|
>
|
||||||
View File {index + 1}
|
View File
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1925,7 +1771,7 @@ const EventRequestDetails = ({
|
||||||
|
|
||||||
{/* Confirmation modal */}
|
{/* Confirmation modal */}
|
||||||
{isConfirmModalOpen && (
|
{isConfirmModalOpen && (
|
||||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[300] flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-xs z-300 flex items-center justify-center p-4">
|
||||||
<div className="bg-base-300 rounded-lg p-6 w-full max-w-md">
|
<div className="bg-base-300 rounded-lg p-6 w-full max-w-md">
|
||||||
<h3 className="text-lg font-bold mb-4">Confirm Status Change</h3>
|
<h3 className="text-lg font-bold mb-4">Confirm Status Change</h3>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
|
@ -1958,56 +1804,6 @@ const EventRequestDetails = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Decline Reason Modal */}
|
|
||||||
{isDeclineModalOpen && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[300] flex items-center justify-center p-4">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
|
||||||
className="bg-base-300 rounded-lg p-6 w-full max-w-md"
|
|
||||||
>
|
|
||||||
<h3 className="text-lg font-bold mb-4">Decline Event Request</h3>
|
|
||||||
<p className="text-gray-300 mb-4">
|
|
||||||
Please provide a reason for declining "{request.name}". This will be sent to the submitter and they will need to resubmit with proper information.
|
|
||||||
</p>
|
|
||||||
<textarea
|
|
||||||
className="textarea textarea-bordered w-full h-32 bg-base-100 text-white border-base-300 focus:border-primary"
|
|
||||||
placeholder="Enter decline reason (required)..."
|
|
||||||
value={declineReason}
|
|
||||||
onChange={(e) => setDeclineReason(e.target.value)}
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
<div className="text-xs text-gray-400 mb-4">
|
|
||||||
{declineReason.length}/500 characters
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost"
|
|
||||||
onClick={cancelDecline}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-error"
|
|
||||||
onClick={handleDeclineWithReason}
|
|
||||||
disabled={!declineReason.trim() || isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<span className="loading loading-spinner loading-xs"></span>
|
|
||||||
Declining...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Decline Request'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File Preview Modal */}
|
{/* File Preview Modal */}
|
||||||
<dialog id="file-preview-modal" className="modal modal-bottom sm:modal-middle">
|
<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="modal-box bg-base-200 p-0 overflow-hidden max-w-4xl">
|
||||||
|
|
|
@ -24,8 +24,6 @@ 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 {
|
||||||
|
@ -44,18 +42,14 @@ 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>('active');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||||
const [sortField, setSortField] = useState<string>('start_date_time');
|
const [sortField, setSortField] = useState<string>('created');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
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 () => {
|
||||||
|
@ -68,14 +62,13 @@ const EventRequestManagementTable = ({
|
||||||
|
|
||||||
// console.log("Fetching event requests...");
|
// console.log("Fetching event requests...");
|
||||||
|
|
||||||
// Use DataSyncService to get data from IndexedDB with forced sync and deletion detection
|
// Use DataSyncService to get data from IndexedDB with forced sync
|
||||||
const updatedRequests = await dataSync.getData<ExtendedEventRequest>(
|
const updatedRequests = await dataSync.getData<ExtendedEventRequest>(
|
||||||
Collections.EVENT_REQUESTS,
|
Collections.EVENT_REQUESTS,
|
||||||
true, // Force sync
|
true, // Force sync
|
||||||
'', // No filter - get all requests
|
'', // No filter
|
||||||
'-created',
|
'-created',
|
||||||
'requested_user', // Expand user data
|
'requested_user' // This is correct but we need to ensure it's working in the DataSyncService
|
||||||
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
|
||||||
|
@ -137,19 +130,9 @@ const EventRequestManagementTable = ({
|
||||||
|
|
||||||
// Apply status filter
|
// Apply status filter
|
||||||
if (statusFilter !== 'all') {
|
if (statusFilter !== 'all') {
|
||||||
if (statusFilter === 'active') {
|
filtered = filtered.filter(request =>
|
||||||
// Filter to show only submitted and pending events (hide completed and declined)
|
request.status?.toLowerCase() === statusFilter.toLowerCase()
|
||||||
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
|
||||||
|
@ -194,125 +177,40 @@ const EventRequestManagementTable = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update event request status
|
// Update event request status
|
||||||
const updateEventRequestStatus = async (id: string, status: "submitted" | "pending" | "completed" | "declined", declineReason?: string): Promise<void> => {
|
const updateEventRequestStatus = async (id: string, status: "submitted" | "pending" | "completed" | "declined"): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// 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;
|
|
||||||
|
|
||||||
// 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);
|
await onStatusChange(id, status);
|
||||||
}
|
|
||||||
|
|
||||||
// Update local state
|
// Find the event request to get its name
|
||||||
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, flyers_completed: completed } : request
|
request.id === id ? { ...request, status } : request
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
setFilteredRequests(prev =>
|
setFilteredRequests(prev =>
|
||||||
prev.map(request =>
|
prev.map(request =>
|
||||||
request.id === id ? { ...request, flyers_completed: completed } : request
|
request.id === id ? { ...request, status } : request
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
toast.success(`"${eventName}" PR status updated to ${completed ? 'completed' : 'pending'}`);
|
// Force sync to update IndexedDB
|
||||||
|
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) {
|
||||||
console.error('Error updating PR status:', error);
|
// Find the event request to get its name
|
||||||
toast.error('Failed to update PR status');
|
const eventRequest = eventRequests.find(req => req.id === id);
|
||||||
|
const eventName = eventRequest?.name || 'Event';
|
||||||
|
|
||||||
|
// console.error('Error updating status:', error);
|
||||||
|
toast.error(`Failed to update status for "${eventName}"`);
|
||||||
|
throw error; // Re-throw the error to be caught by the caller
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -333,50 +231,6 @@ const EventRequestManagementTable = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format date and time range for display
|
|
||||||
const formatDateTimeRange = (startDateString: string, endDateString: string) => {
|
|
||||||
if (!startDateString) return 'Not specified';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const startDate = new Date(startDateString);
|
|
||||||
const endDate = endDateString ? new Date(endDateString) : null;
|
|
||||||
|
|
||||||
const startFormatted = startDate.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (endDate && endDate.getTime() !== startDate.getTime()) {
|
|
||||||
// Check if it's the same day
|
|
||||||
const isSameDay = startDate.toDateString() === endDate.toDateString();
|
|
||||||
|
|
||||||
if (isSameDay) {
|
|
||||||
// Same day, just show end time
|
|
||||||
const endTime = endDate.toLocaleTimeString('en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
return `${startFormatted} - ${endTime}`;
|
|
||||||
} else {
|
|
||||||
// Different day, show full end date
|
|
||||||
const endFormatted = endDate.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
return `${startFormatted} - ${endFormatted}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return startFormatted;
|
|
||||||
} catch (e) {
|
|
||||||
return startDateString;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get status badge class based on status
|
// 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';
|
||||||
|
@ -448,42 +302,10 @@ const EventRequestManagementTable = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle decline action with reason prompt
|
|
||||||
const handleDeclineAction = (request: ExtendedEventRequest) => {
|
|
||||||
setRequestToDecline(request);
|
|
||||||
setDeclineReason('');
|
|
||||||
setIsDeclineModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Confirm decline with reason
|
|
||||||
const confirmDecline = async () => {
|
|
||||||
if (!requestToDecline || !declineReason.trim()) {
|
|
||||||
toast.error('Please provide a reason for declining');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateEventRequestStatus(requestToDecline.id, 'declined', declineReason);
|
|
||||||
setIsDeclineModalOpen(false);
|
|
||||||
setRequestToDecline(null);
|
|
||||||
setDeclineReason('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error declining request:', error);
|
|
||||||
toast.error('Failed to decline request');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cancel decline action
|
|
||||||
const cancelDecline = () => {
|
|
||||||
setIsDeclineModalOpen(false);
|
|
||||||
setRequestToDecline(null);
|
|
||||||
setDeclineReason('');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply filters when filter state changes
|
// Apply filters when filter state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyFilters();
|
applyFilters();
|
||||||
}, [statusFilter, searchTerm, sortField, sortDirection, eventRequests]);
|
}, [statusFilter, searchTerm, sortField, sortDirection]);
|
||||||
|
|
||||||
// Check authentication and refresh token if needed
|
// Check authentication and refresh token if needed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -542,7 +364,7 @@ const EventRequestManagementTable = ({
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="bg-gradient-to-b from-base-200 to-base-300 rounded-xl p-8 text-center shadow-sm border border-base-300/30"
|
className="bg-linear-to-b from-base-200 to-base-300 rounded-xl p-8 text-center shadow-xs border border-base-300/30"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center justify-center py-6">
|
<div className="flex flex-col items-center justify-center py-6">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
@ -625,7 +447,6 @@ 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>
|
||||||
|
@ -662,13 +483,13 @@ const EventRequestManagementTable = ({
|
||||||
|
|
||||||
{/* Event requests table */}
|
{/* Event requests table */}
|
||||||
<div
|
<div
|
||||||
className="rounded-xl shadow-sm overflow-x-auto bg-base-100/10 border border-base-100/20"
|
className="rounded-xl shadow-xs overflow-x-auto bg-base-100/10 border border-base-100/20"
|
||||||
style={{
|
style={{
|
||||||
maxHeight: "unset",
|
maxHeight: "unset",
|
||||||
height: "auto"
|
height: "auto"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<table className="table table-zebra w-full min-w-[600px]">
|
<table className="table table-zebra w-full">
|
||||||
<thead className="bg-base-300/50 sticky top-0 z-10">
|
<thead className="bg-base-300/50 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
|
@ -689,7 +510,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 & Time
|
Date
|
||||||
{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"} />
|
||||||
|
@ -711,19 +532,6 @@ 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"
|
||||||
|
@ -751,7 +559,7 @@ const EventRequestManagementTable = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="w-20 min-w-[5rem]">Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -762,11 +570,7 @@ const EventRequestManagementTable = ({
|
||||||
{truncateText(request.name, 30)}
|
{truncateText(request.name, 30)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden md:table-cell">
|
<td className="hidden md:table-cell">{formatDate(request.start_date_time)}</td>
|
||||||
<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);
|
||||||
|
@ -785,28 +589,6 @@ 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>
|
||||||
|
@ -821,17 +603,16 @@ const EventRequestManagementTable = ({
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-primary btn-outline gap-1 min-h-[2rem] max-w-[5rem] flex-shrink-0"
|
className="btn btn-sm btn-primary btn-outline btn-sm gap-2"
|
||||||
onClick={() => openDetailModal(request)}
|
onClick={() => openDetailModal(request)}
|
||||||
title="View Event Details"
|
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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>
|
||||||
<span className="hidden sm:inline">View</span>
|
View
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
@ -841,50 +622,6 @@ 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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,7 +7,6 @@ import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||||
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,14 +268,9 @@ 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 with deletion detection enabled
|
// Force sync to update IndexedDB
|
||||||
const dataSync = DataSyncService.getInstance();
|
const dataSync = DataSyncService.getInstance();
|
||||||
await dataSync.syncCollection(Collections.EVENT_REQUESTS, "", "-created", {}, true);
|
await dataSync.syncCollection(Collections.EVENT_REQUESTS);
|
||||||
|
|
||||||
// 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 =>
|
||||||
|
@ -285,18 +279,13 @@ const EventRequestModal: React.FC<EventRequestModalProps> = ({ eventRequests })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Find the request to get its name
|
||||||
|
const request = localEventRequests.find((req) => req.id === id);
|
||||||
|
const eventName = request?.name || "Event";
|
||||||
|
|
||||||
// Notify success
|
// 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", {
|
||||||
|
@ -343,7 +332,7 @@ const EventRequestModal: React.FC<EventRequestModalProps> = ({ eventRequests })
|
||||||
<>
|
<>
|
||||||
{/* Table component with modernized UI */}
|
{/* Table component with modernized UI */}
|
||||||
<div
|
<div
|
||||||
className="bg-gradient-to-b from-base-200 to-base-300 rounded-xl shadow-xl overflow-hidden dashboard-card card-enter event-table-container border border-base-300/30"
|
className="bg-linear-to-b from-base-200 to-base-300 rounded-xl shadow-xl overflow-hidden dashboard-card card-enter event-table-container border border-base-300/30"
|
||||||
style={{ animationDelay: ANIMATION_DELAY }}
|
style={{ animationDelay: ANIMATION_DELAY }}
|
||||||
>
|
>
|
||||||
<div className="p-4 md:p-6 h-auto">
|
<div className="p-4 md:p-6 h-auto">
|
||||||
|
@ -366,7 +355,7 @@ const EventRequestModal: React.FC<EventRequestModalProps> = ({ eventRequests })
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[200]"
|
className="fixed inset-0 bg-black/70 backdrop-blur-xs z-200"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center min-h-screen p-4 overflow-hidden">
|
<div className="flex items-center justify-center min-h-screen p-4 overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
@ -374,9 +363,9 @@ const EventRequestModal: React.FC<EventRequestModalProps> = ({ eventRequests })
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
exit={{ scale: 0.95, opacity: 0 }}
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||||
className="w-full max-w-5xl max-h-[90vh] overflow-y-auto bg-gradient-to-b from-base-200 to-base-300 rounded-xl shadow-2xl border border-base-100/20 relative"
|
className="w-full max-w-5xl max-h-[90vh] overflow-y-auto bg-linear-to-b from-base-200 to-base-300 rounded-xl shadow-2xl border border-base-100/20 relative"
|
||||||
>
|
>
|
||||||
<div className="sticky top-0 right-0 z-[201] flex justify-between items-center p-4 bg-base-300/80 backdrop-blur-md border-b border-base-100/10">
|
<div className="sticky top-0 right-0 z-201 flex justify-between items-center p-4 bg-base-300/80 backdrop-blur-md border-b border-base-100/10">
|
||||||
<h2 className="text-xl font-bold text-white">{selectedRequest.name}</h2>
|
<h2 className="text-xl font-bold text-white">{selectedRequest.name}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={closeModal}
|
onClick={closeModal}
|
||||||
|
|
|
@ -17,7 +17,7 @@ import { Stats } from "./ProfileSection/Stats";
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-start space-x-3">
|
<div class="flex items-start space-x-3">
|
||||||
<div class="flex-shrink-0 mt-0.5">
|
<div class="shrink-0 mt-0.5">
|
||||||
<div class="p-1.5 bg-error/20 rounded-full">
|
<div class="p-1.5 bg-error/20 rounded-full">
|
||||||
<Icon
|
<Icon
|
||||||
name="heroicons:document-text"
|
name="heroicons:document-text"
|
||||||
|
@ -36,10 +36,10 @@ import { Stats } from "./ProfileSection/Stats";
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4 flex-shrink-0 self-center">
|
<div class="ml-4 shrink-0 self-center">
|
||||||
<a
|
<a
|
||||||
href="#settings-section"
|
href="#settings-section"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-error hover:bg-error-focus focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-error transition-colors duration-200"
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-xs text-white bg-error hover:bg-error-focus focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-error transition-colors duration-200"
|
||||||
id="uploadResumeBtn"
|
id="uploadResumeBtn"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
|
|
|
@ -412,7 +412,7 @@ export default function ShowProfileLogs() {
|
||||||
<path fillRule="evenodd" d="M4.755 10.059a7.5 7.5 0 0112.548-3.364l1.903 1.903h-3.183a.75.75 0 100 1.5h4.992a.75.75 0 00.75-.75V4.356a.75.75 0 00-1.5 0v3.18l-1.9-1.9A9 9 0 003.306 9.67a.75.75 0 101.45.388zm15.408 3.352a.75.75 0 00-.919.53 7.5 7.5 0 01-12.548 3.364l-1.902-1.903h3.183a.75.75 0 000-1.5H2.984a.75.75 0 00-.75.75v4.992a.75.75 0 001.5 0v-3.18l1.9 1.9a9 9 0 0015.059-4.035.75.75 0 00-.53-.918z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M4.755 10.059a7.5 7.5 0 0112.548-3.364l1.903 1.903h-3.183a.75.75 0 100 1.5h4.992a.75.75 0 00.75-.75V4.356a.75.75 0 00-1.5 0v3.18l-1.9-1.9A9 9 0 003.306 9.67a.75.75 0 101.45.388zm15.408 3.352a.75.75 0 00-.919.53 7.5 7.5 0 01-12.548 3.364l-1.902-1.903h3.183a.75.75 0 000-1.5H2.984a.75.75 0 00-.75.75v4.992a.75.75 0 001.5 0v-3.18l1.9 1.9a9 9 0 0015.059-4.035.75.75 0 00-.53-.918z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
|
<ul tabIndex={0} className="dropdown-content z-1 menu p-2 shadow-sm bg-base-100 rounded-box w-52">
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
|
@ -449,7 +449,7 @@ export default function ShowProfileLogs() {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{logs.map((log) => (
|
{logs.map((log) => (
|
||||||
<div key={log.id} className="flex items-start gap-4 p-4 rounded-lg hover:bg-base-200 transition-colors duration-200">
|
<div key={log.id} className="flex items-start gap-4 p-4 rounded-lg hover:bg-base-200 transition-colors duration-200">
|
||||||
<div className="flex-shrink-0">
|
<div className="shrink-0">
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${getLogTypeColor(log.type)}`}>
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${getLogTypeColor(log.type)}`}>
|
||||||
{getLogTypeIcon(log.type)}
|
{getLogTypeIcon(log.type)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -109,7 +109,7 @@ export default function ResumeDetail() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Student Information */}
|
{/* Student Information */}
|
||||||
<div className="flex flex-col md:flex-row gap-6">
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
<div className="flex-shrink-0">
|
<div className="shrink-0">
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
<div className="w-24 h-24 rounded-xl">
|
<div className="w-24 h-24 rounded-xl">
|
||||||
{user.avatar ? (
|
{user.avatar ? (
|
||||||
|
@ -123,7 +123,7 @@ export default function ResumeDetail() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-grow">
|
<div className="grow">
|
||||||
<h3 className="text-xl font-bold">{user.name}</h3>
|
<h3 className="text-xl font-bold">{user.name}</h3>
|
||||||
<p className="text-base-content/70">{user.email}</p>
|
<p className="text-base-content/70">{user.email}</p>
|
||||||
|
|
||||||
|
|
|
@ -41,7 +41,7 @@ export default function ResumeSearch() {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="relative flex-grow">
|
<div className="relative grow">
|
||||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
@ -51,7 +51,7 @@ export default function ResumeSearch() {
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name or major..."
|
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
|
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
|
bg-white/90 dark:bg-gray-800/90 text-gray-700 dark:text-gray-200 focus:outline-hidden
|
||||||
focus:ring-2 focus:ring-primary focus:border-transparent"
|
focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
|
|
|
@ -5,6 +5,7 @@ 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
|
||||||
|
@ -130,6 +131,27 @@ 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"
|
||||||
|
|
|
@ -228,7 +228,7 @@ export default function EmailRequestSettings() {
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-xl font-mono bg-base-100 p-2 rounded">
|
<p className="text-xl font-mono bg-base-100 p-2 rounded-sm">
|
||||||
{createdEmail || (user ? `${user.email.split('@')[0].toLowerCase().replace(/[^a-z0-9]/g, "")}@ieeeucsd.org` : '')}
|
{createdEmail || (user ? `${user.email.split('@')[0].toLowerCase().replace(/[^a-z0-9]/g, "")}@ieeeucsd.org` : '')}
|
||||||
</p>
|
</p>
|
||||||
{initialPassword ? (
|
{initialPassword ? (
|
||||||
|
@ -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@ieeeatucsd.org" className="underline">webmaster@ieeeatucsd.org</a>
|
If you have any questions or need help with your IEEE email, please contact <a href="mailto:webmaster@ieeeucsd.org" className="underline">webmaster@ieeeucsd.org</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -398,7 +398,7 @@ export default function EmailRequestSettings() {
|
||||||
<div className="p-4 bg-base-200 rounded-lg">
|
<div className="p-4 bg-base-200 rounded-lg">
|
||||||
<h3 className="font-bold text-lg mb-2">Your IEEE Email Address</h3>
|
<h3 className="font-bold text-lg mb-2">Your IEEE Email Address</h3>
|
||||||
<p className="text-sm mb-2">When you request an email, you'll receive:</p>
|
<p className="text-sm mb-2">When you request an email, you'll receive:</p>
|
||||||
<p className="text-xl font-mono bg-base-100 p-2 rounded">
|
<p className="text-xl font-mono bg-base-100 p-2 rounded-sm">
|
||||||
{user?.email ? `${user.email.split('@')[0].toLowerCase().replace(/[^a-z0-9]/g, "")}@ieeeucsd.org` : 'Loading...'}
|
{user?.email ? `${user.email.split('@')[0].toLowerCase().replace(/[^a-z0-9]/g, "")}@ieeeucsd.org` : 'Loading...'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -480,7 +480,7 @@ export default function PasswordChangeSettings({
|
||||||
{debugInfo && (
|
{debugInfo && (
|
||||||
<div className="mt-4 border-t pt-2">
|
<div className="mt-4 border-t pt-2">
|
||||||
<p className="font-semibold">Debug Info:</p>
|
<p className="font-semibold">Debug Info:</p>
|
||||||
<div className="overflow-auto max-h-60 bg-base-300 p-2 rounded text-xs">
|
<div className="overflow-auto max-h-60 bg-base-300 p-2 rounded-sm text-xs">
|
||||||
<pre>{JSON.stringify(debugInfo, null, 2)}</pre>
|
<pre>{JSON.stringify(debugInfo, null, 2)}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -38,7 +38,7 @@ import EventTimeline from "./SponsorAnalyticsSection/EventTimeline";
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"
|
class="dropdown-content z-1 menu p-2 shadow-sm bg-base-100 rounded-box w-52"
|
||||||
>
|
>
|
||||||
<li><a data-time-range="30">Last 30 Days</a></li>
|
<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="90">Last 90 Days</a></li>
|
||||||
|
|
|
@ -4,7 +4,6 @@ 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;
|
||||||
|
@ -67,35 +66,6 @@ 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]) {
|
||||||
|
@ -174,69 +144,6 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseJsonData = () => {
|
|
||||||
try {
|
|
||||||
if (!jsonInput.trim()) {
|
|
||||||
toast.error('Please enter JSON data to parse');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(jsonInput);
|
|
||||||
|
|
||||||
// Validate the structure
|
|
||||||
if (!parsed.itemized_expenses || !Array.isArray(parsed.itemized_expenses)) {
|
|
||||||
throw new Error('itemized_expenses must be an array');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate each expense item
|
|
||||||
for (const item of parsed.itemized_expenses) {
|
|
||||||
if (!item.description || !item.category || typeof item.amount !== 'number') {
|
|
||||||
throw new Error('Each expense item must have description, category, and amount');
|
|
||||||
}
|
|
||||||
if (!EXPENSE_CATEGORIES.includes(item.category)) {
|
|
||||||
throw new Error(`Invalid category: ${item.category}. Must be one of: ${EXPENSE_CATEGORIES.join(', ')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate the form fields
|
|
||||||
setItemizedExpenses(parsed.itemized_expenses);
|
|
||||||
if (parsed.tax !== undefined) setTax(Number(parsed.tax) || 0);
|
|
||||||
if (parsed.date) setDate(parsed.date);
|
|
||||||
if (parsed.location_name) setLocationName(parsed.location_name);
|
|
||||||
if (parsed.location_address) setLocationAddress(parsed.location_address);
|
|
||||||
if (parsed.notes) setNotes(parsed.notes);
|
|
||||||
|
|
||||||
setError('');
|
|
||||||
toast.success(`Successfully imported ${parsed.itemized_expenses.length} expense items`);
|
|
||||||
setShowJsonInput(false);
|
|
||||||
setJsonInput('');
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Invalid JSON format';
|
|
||||||
setError(`JSON Parse Error: ${errorMessage}`);
|
|
||||||
toast.error(`Failed to parse JSON: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyToClipboard = (text: string) => {
|
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
|
||||||
toast.success('Sample data copied to clipboard!');
|
|
||||||
}).catch(() => {
|
|
||||||
toast.error('Failed to copy to clipboard');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoomIn = () => {
|
|
||||||
setZoomLevel(prev => Math.min(prev + 0.25, 3));
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoomOut = () => {
|
|
||||||
setZoomLevel(prev => Math.max(prev - 0.25, 0.5));
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetZoom = () => {
|
|
||||||
setZoomLevel(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
|
@ -288,9 +195,8 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Date and Location in Grid */}
|
{/* Date */}
|
||||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
<div className="form-control">
|
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text font-medium">Date</span>
|
<span className="label-text font-medium">Date</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
|
@ -302,27 +208,10 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
onChange={(e) => setDate(e.target.value)}
|
onChange={(e) => setDate(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label">
|
|
||||||
<span className="label-text font-medium">Tax Amount ($)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
|
||||||
value={tax === 0 ? '' : tax}
|
|
||||||
onChange={(e) => setTax(Number(e.target.value))}
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Location Fields */}
|
{/* Location Name */}
|
||||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
<div className="form-control">
|
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text font-medium">Location Name</span>
|
<span className="label-text font-medium">Location Name</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
|
@ -332,12 +221,12 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
value={locationName}
|
value={locationName}
|
||||||
onChange={(e) => setLocationName(e.target.value)}
|
onChange={(e) => setLocationName(e.target.value)}
|
||||||
placeholder="Store/vendor name"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="form-control">
|
{/* Location Address */}
|
||||||
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text font-medium">Location Address</span>
|
<span className="label-text font-medium">Location Address</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
|
@ -347,211 +236,27 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
value={locationAddress}
|
value={locationAddress}
|
||||||
onChange={(e) => setLocationAddress(e.target.value)}
|
onChange={(e) => setLocationAddress(e.target.value)}
|
||||||
placeholder="Full address"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Notes - Reduced height */}
|
{/* 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"
|
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[100px]"
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
rows={2}
|
rows={3}
|
||||||
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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{itemizedExpenses.map((item, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={index}
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: 20 }}
|
|
||||||
className="card bg-base-200/50 hover:bg-base-200 transition-colors duration-300 backdrop-blur-sm shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="card-body p-3">
|
|
||||||
<div className="flex justify-between items-center mb-2">
|
|
||||||
<h4 className="text-sm font-medium">Item #{index + 1}</h4>
|
|
||||||
{itemizedExpenses.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-xs btn-ghost text-error hover:bg-error/10"
|
|
||||||
onClick={() => removeExpenseItem(index)}
|
|
||||||
aria-label="Remove item"
|
|
||||||
>
|
|
||||||
<Icon icon="heroicons:trash" className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label py-1">
|
|
||||||
<span className="label-text text-xs">Description</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="input input-bordered input-sm"
|
|
||||||
value={item.description}
|
|
||||||
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
|
|
||||||
placeholder="What was purchased?"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label py-1">
|
|
||||||
<span className="label-text text-xs">Category</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="select select-bordered select-sm w-full"
|
|
||||||
value={item.category}
|
|
||||||
onChange={(e) => handleExpenseItemChange(index, 'category', e.target.value)}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select...</option>
|
|
||||||
{EXPENSE_CATEGORIES.map(category => (
|
|
||||||
<option key={category} value={category}>{category}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label py-1">
|
|
||||||
<span className="label-text text-xs">Amount ($)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input input-bordered input-sm w-full"
|
|
||||||
value={item.amount === 0 ? '' : item.amount}
|
|
||||||
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))}
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
placeholder="0.00"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Add Item Button - Moved to bottom */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="flex justify-center pt-2"
|
|
||||||
>
|
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
|
@ -562,24 +267,119 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
<Icon icon="heroicons:plus" className="h-4 w-4" />
|
<Icon icon="heroicons:plus" className="h-4 w-4" />
|
||||||
Add Item
|
Add Item
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{itemizedExpenses.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
className="card bg-base-200/50 hover:bg-base-200 transition-colors duration-300 backdrop-blur-xs shadow-xs overflow-visible"
|
||||||
|
>
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<div className="grid gap-4 overflow-visible">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Description</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered"
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h4 className="text-sm font-medium">Item #{index + 1}</h4>
|
||||||
|
{itemizedExpenses.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost text-error hover:bg-error/10"
|
||||||
|
onClick={() => removeExpenseItem(index)}
|
||||||
|
aria-label="Remove item"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:trash" className="h-4 w-4" />
|
||||||
|
<span className="text-xs">Remove</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Category</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered w-full"
|
||||||
|
value={item.category}
|
||||||
|
onChange={(e) => handleExpenseItemChange(index, 'category', e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select category</option>
|
||||||
|
{EXPENSE_CATEGORIES.map(category => (
|
||||||
|
<option key={category} value={category}>{category}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Amount ($)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
value={item.amount === 0 ? '' : item.amount}
|
||||||
|
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))}
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Tax */}
|
||||||
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-medium">Tax Amount ($)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
|
value={tax === 0 ? '' : tax}
|
||||||
|
onChange={(e) => setTax(Number(e.target.value))}
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Total */}
|
{/* Total */}
|
||||||
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-3 shadow-sm">
|
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-xs p-4 shadow-xs">
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center text-sm text-base-content/70">
|
<div className="flex justify-between items-center text-base-content/70">
|
||||||
<span>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-sm text-base-content/70">
|
<div className="flex justify-between items-center 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">
|
<div className="flex justify-between items-center font-medium text-lg">
|
||||||
<span>Total:</span>
|
<span>Total:</span>
|
||||||
<span className="font-mono text-primary text-lg">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span>
|
<span className="font-mono text-primary">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
@ -622,60 +422,13 @@ 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 shadow-sm relative"
|
className="bg-base-200/50 backdrop-blur-xs rounded-xl p-4 shadow-xs"
|
||||||
>
|
>
|
||||||
{/* Zoom Controls */}
|
<FilePreview
|
||||||
<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">
|
url={previewUrl}
|
||||||
<motion.button
|
filename={file?.name || ''}
|
||||||
whileHover={{ scale: 1.1 }}
|
isModal={false}
|
||||||
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
|
||||||
|
|
|
@ -3,7 +3,6 @@ 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';
|
||||||
|
@ -332,14 +331,6 @@ export default function ReimbursementForm() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send email notification
|
|
||||||
try {
|
|
||||||
await EmailClient.notifySubmission(newReimbursement.id);
|
|
||||||
} catch (emailError) {
|
|
||||||
console.error('Failed to send submission email notification:', emailError);
|
|
||||||
// Don't fail the entire operation if email fails
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} 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.');
|
||||||
|
@ -454,7 +445,7 @@ export default function ReimbursementForm() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Receipts */}
|
{/* Receipts */}
|
||||||
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-6 shadow-sm">
|
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-xs p-6 shadow-xs">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="text-lg font-medium">Receipts</h3>
|
<h3 className="text-lg font-medium">Receipts</h3>
|
||||||
<motion.button
|
<motion.button
|
||||||
|
@ -478,7 +469,7 @@ export default function ReimbursementForm() {
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -20 }}
|
exit={{ opacity: 0, y: -20 }}
|
||||||
className="card bg-base-100 hover:bg-base-200 transition-all duration-300 shadow-sm"
|
className="card bg-base-100 hover:bg-base-200 transition-all duration-300 shadow-xs"
|
||||||
>
|
>
|
||||||
<div className="card-body p-4">
|
<div className="card-body p-4">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
|
@ -571,7 +562,7 @@ export default function ReimbursementForm() {
|
||||||
className="modal-box max-w-5xl bg-base-100/95 backdrop-blur-md"
|
className="modal-box max-w-5xl bg-base-100/95 backdrop-blur-md"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">Add Receipt</h3>
|
<h3 className="text-2xl font-bold bg-linear-to-r from-primary to-secondary bg-clip-text text-transparent">Add Receipt</h3>
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.1, rotate: 90 }}
|
whileHover={{ scale: 1.1, rotate: 90 }}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
|
@ -607,7 +598,7 @@ export default function ReimbursementForm() {
|
||||||
className="modal-box max-w-4xl bg-base-100/95 backdrop-blur-md"
|
className="modal-box max-w-4xl bg-base-100/95 backdrop-blur-md"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
<h3 className="text-2xl font-bold bg-linear-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||||
Receipt Details
|
Receipt Details
|
||||||
</h3>
|
</h3>
|
||||||
<motion.button
|
<motion.button
|
||||||
|
@ -683,7 +674,7 @@ export default function ReimbursementForm() {
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="text-lg font-medium">Receipt Image</h3>
|
<h3 className="text-lg font-medium">Receipt Image</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-base-200/50 backdrop-blur-sm rounded-lg p-4 shadow-sm">
|
<div className="bg-base-200/50 backdrop-blur-xs rounded-lg p-4 shadow-xs">
|
||||||
<FilePreview
|
<FilePreview
|
||||||
url={URL.createObjectURL(selectedReceiptDetails.file)}
|
url={URL.createObjectURL(selectedReceiptDetails.file)}
|
||||||
filename={selectedReceiptDetails.file.name}
|
filename={selectedReceiptDetails.file.name}
|
||||||
|
|
|
@ -492,7 +492,7 @@ export default function ReimbursementList() {
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
className="text-center py-16 bg-base-200/50 backdrop-blur-sm rounded-2xl border-2 border-dashed border-base-300"
|
className="text-center py-16 bg-base-200/50 backdrop-blur-xs rounded-2xl border-2 border-dashed border-base-300"
|
||||||
>
|
>
|
||||||
<Icon icon="heroicons:document" className="h-16 w-16 mx-auto text-base-content/30" />
|
<Icon icon="heroicons:document" className="h-16 w-16 mx-auto text-base-content/30" />
|
||||||
<h3 className="mt-6 text-xl font-medium">No reimbursement requests</h3>
|
<h3 className="mt-6 text-xl font-medium">No reimbursement requests</h3>
|
||||||
|
@ -516,7 +516,7 @@ export default function ReimbursementList() {
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
layout
|
layout
|
||||||
className="card bg-base-100 hover:bg-base-200 transition-all duration-300 border border-base-200 hover:border-primary shadow-sm hover:shadow-md"
|
className="card bg-base-100 hover:bg-base-200 transition-all duration-300 border border-base-200 hover:border-primary shadow-xs hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="card-body p-5">
|
<div className="card-body p-5">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
|
@ -541,7 +541,7 @@ export default function ReimbursementList() {
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
className="btn btn-primary btn-sm gap-2 shadow-sm hover:shadow-md transition-all duration-300"
|
className="btn btn-primary btn-sm gap-2 shadow-xs hover:shadow-md transition-all duration-300"
|
||||||
onClick={() => setSelectedRequest(request)}
|
onClick={() => setSelectedRequest(request)}
|
||||||
>
|
>
|
||||||
<Icon icon="heroicons:eye" className="h-4 w-4" />
|
<Icon icon="heroicons:eye" className="h-4 w-4" />
|
||||||
|
@ -549,7 +549,7 @@ export default function ReimbursementList() {
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
<div className="mt-4 card bg-base-200/50 backdrop-blur-xs p-4 shadow-xs">
|
||||||
<div className="flex items-center justify-between w-full relative py-2">
|
<div className="flex items-center justify-between w-full relative py-2">
|
||||||
<div className="absolute left-0 right-0 top-1/2 h-0.5 bg-base-300 -translate-y-[1.0rem]" />
|
<div className="absolute left-0 right-0 top-1/2 h-0.5 bg-base-300 -translate-y-[1.0rem]" />
|
||||||
{STATUS_ORDER.map((status, index) => {
|
{STATUS_ORDER.map((status, index) => {
|
||||||
|
@ -623,7 +623,7 @@ export default function ReimbursementList() {
|
||||||
className="modal-box max-w-3xl bg-base-100/95 backdrop-blur-md"
|
className="modal-box max-w-3xl bg-base-100/95 backdrop-blur-md"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
<h3 className="text-2xl font-bold bg-linear-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||||
{selectedRequest.title}
|
{selectedRequest.title}
|
||||||
</h3>
|
</h3>
|
||||||
<motion.button
|
<motion.button
|
||||||
|
@ -638,44 +638,44 @@ export default function ReimbursementList() {
|
||||||
|
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
<div className="card bg-base-200/50 backdrop-blur-xs p-4 shadow-xs">
|
||||||
<label className="text-sm font-medium text-base-content/70">Status</label>
|
<label className="text-sm font-medium text-base-content/70">Status</label>
|
||||||
<div className={`badge ${STATUS_COLORS[selectedRequest.status]} badge-lg gap-1 mt-1`}>
|
<div className={`badge ${STATUS_COLORS[selectedRequest.status]} badge-lg gap-1 mt-1`}>
|
||||||
<Icon icon={STATUS_ICONS[selectedRequest.status]} className="h-4 w-4" />
|
<Icon icon={STATUS_ICONS[selectedRequest.status]} className="h-4 w-4" />
|
||||||
{STATUS_LABELS[selectedRequest.status]}
|
{STATUS_LABELS[selectedRequest.status]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
<div className="card bg-base-200/50 backdrop-blur-xs p-4 shadow-xs">
|
||||||
<label className="text-sm font-medium text-base-content/70">Department</label>
|
<label className="text-sm font-medium text-base-content/70">Department</label>
|
||||||
<div className="badge badge-outline badge-lg mt-1">
|
<div className="badge badge-outline badge-lg mt-1">
|
||||||
{DEPARTMENT_LABELS[selectedRequest.department]}
|
{DEPARTMENT_LABELS[selectedRequest.department]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
<div className="card bg-base-200/50 backdrop-blur-xs p-4 shadow-xs">
|
||||||
<label className="text-sm font-medium text-base-content/70">Total Amount</label>
|
<label className="text-sm font-medium text-base-content/70">Total Amount</label>
|
||||||
<p className="mt-1 text-xl font-mono font-bold text-primary">
|
<p className="mt-1 text-xl font-mono font-bold text-primary">
|
||||||
${selectedRequest.total_amount.toFixed(2)}
|
${selectedRequest.total_amount.toFixed(2)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
<div className="card bg-base-200/50 backdrop-blur-xs p-4 shadow-xs">
|
||||||
<label className="text-sm font-medium text-base-content/70">Date of Purchase</label>
|
<label className="text-sm font-medium text-base-content/70">Date of Purchase</label>
|
||||||
<p className="mt-1 font-medium">{formatDate(selectedRequest.date_of_purchase)}</p>
|
<p className="mt-1 font-medium">{formatDate(selectedRequest.date_of_purchase)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm col-span-2">
|
<div className="card bg-base-200/50 backdrop-blur-xs p-4 shadow-xs col-span-2">
|
||||||
<label className="text-sm font-medium text-base-content/70">Payment Method</label>
|
<label className="text-sm font-medium text-base-content/70">Payment Method</label>
|
||||||
<p className="mt-1 font-medium">{selectedRequest.payment_method}</p>
|
<p className="mt-1 font-medium">{selectedRequest.payment_method}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedRequest.additional_info && (
|
{selectedRequest.additional_info && (
|
||||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
<div className="card bg-base-200/50 backdrop-blur-xs p-4 shadow-xs">
|
||||||
<label className="text-sm font-medium text-base-content/70">Additional Information</label>
|
<label className="text-sm font-medium text-base-content/70">Additional Information</label>
|
||||||
<p className="mt-2 whitespace-pre-wrap">{selectedRequest.additional_info}</p>
|
<p className="mt-2 whitespace-pre-wrap">{selectedRequest.additional_info}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedRequest.audit_notes && selectedRequest.audit_notes.filter(note => !note.is_private).length > 0 && (
|
{selectedRequest.audit_notes && selectedRequest.audit_notes.filter(note => !note.is_private).length > 0 && (
|
||||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm border-l-4 border-primary">
|
<div className="card bg-base-200/50 backdrop-blur-xs p-4 shadow-xs border-l-4 border-primary">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Icon icon="heroicons:chat-bubble-left-right" className="h-5 w-5 text-primary" />
|
<Icon icon="heroicons:chat-bubble-left-right" className="h-5 w-5 text-primary" />
|
||||||
<label className="text-base font-medium">Public Notes</label>
|
<label className="text-base font-medium">Public Notes</label>
|
||||||
|
@ -699,7 +699,7 @@ export default function ReimbursementList() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
<div className="card bg-base-200/50 backdrop-blur-xs p-4 shadow-xs">
|
||||||
<label className="text-sm font-medium text-base-content/70 mb-2">Receipts</label>
|
<label className="text-sm font-medium text-base-content/70 mb-2">Receipts</label>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
{(selectedRequest.receipts || []).map((receiptId, index) => (
|
{(selectedRequest.receipts || []).map((receiptId, index) => (
|
||||||
|
@ -720,11 +720,11 @@ export default function ReimbursementList() {
|
||||||
<div className="divider before:bg-base-300 after:bg-base-300"></div>
|
<div className="divider before:bg-base-300 after:bg-base-300"></div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
<div className="card bg-base-200/50 backdrop-blur-xs p-4 shadow-xs">
|
||||||
<label className="text-sm font-medium text-base-content/70">Submitted At</label>
|
<label className="text-sm font-medium text-base-content/70">Submitted At</label>
|
||||||
<p className="mt-1">{formatDate(selectedRequest.created)}</p>
|
<p className="mt-1">{formatDate(selectedRequest.created)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
<div className="card bg-base-200/50 backdrop-blur-xs p-4 shadow-xs">
|
||||||
<label className="text-sm font-medium text-base-content/70">Last Updated</label>
|
<label className="text-sm font-medium text-base-content/70">Last Updated</label>
|
||||||
<p className="mt-1">{formatDate(selectedRequest.updated)}</p>
|
<p className="mt-1">{formatDate(selectedRequest.updated)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -752,7 +752,7 @@ export default function ReimbursementList() {
|
||||||
className="modal-box max-w-7xl bg-base-100/95 backdrop-blur-md"
|
className="modal-box max-w-7xl bg-base-100/95 backdrop-blur-md"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
<h3 className="text-2xl font-bold bg-linear-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||||
Receipt Details
|
Receipt Details
|
||||||
</h3>
|
</h3>
|
||||||
<motion.button
|
<motion.button
|
||||||
|
@ -841,7 +841,7 @@ export default function ReimbursementList() {
|
||||||
View Full Size
|
View Full Size
|
||||||
</motion.a>
|
</motion.a>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-base-200/50 backdrop-blur-sm rounded-lg p-4 shadow-sm">
|
<div className="bg-base-200/50 backdrop-blur-xs rounded-lg p-4 shadow-xs">
|
||||||
{previewUrl ? (
|
{previewUrl ? (
|
||||||
<FilePreview
|
<FilePreview
|
||||||
url={previewUrl}
|
url={previewUrl}
|
||||||
|
|
|
@ -5,7 +5,6 @@ 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';
|
||||||
|
@ -33,10 +32,6 @@ 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 {
|
||||||
|
@ -58,11 +53,7 @@ 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);
|
||||||
|
@ -119,21 +110,6 @@ 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})`;
|
||||||
|
@ -184,10 +160,11 @@ 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(
|
||||||
|
@ -223,7 +200,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);
|
||||||
|
|
||||||
receiptMap = Object.fromEntries(
|
const receiptMap = Object.fromEntries(
|
||||||
validReceipts.map(receipt => [receipt.id, receipt])
|
validReceipts.map(receipt => [receipt.id, receipt])
|
||||||
);
|
);
|
||||||
setReceipts(receiptMap);
|
setReceipts(receiptMap);
|
||||||
|
@ -240,52 +217,6 @@ 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.');
|
||||||
|
@ -444,25 +375,14 @@ 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: previousStatus,
|
from: selectedReimbursement?.status,
|
||||||
to: status
|
to: status
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send email notification
|
|
||||||
try {
|
|
||||||
await EmailClient.notifyStatusChange(id, status, previousStatus, userId);
|
|
||||||
} catch (emailError) {
|
|
||||||
console.error('Failed to send email notification:', emailError);
|
|
||||||
// Don't fail the entire operation if email fails
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
toast.success(`Reimbursement ${status} successfully`);
|
toast.success(`Reimbursement ${status} successfully`);
|
||||||
}
|
}
|
||||||
|
@ -663,21 +583,6 @@ 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);
|
||||||
|
@ -777,7 +682,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,0.8fr),minmax(0,1fr)] gap-4 p-2 sm:p-4 max-w-[1600px] mx-auto">
|
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1fr)] gap-4 p-2 sm:p-4 max-w-[1600px] mx-auto">
|
||||||
{/* Left side - List of reimbursements */}
|
{/* Left side - List of reimbursements */}
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
@ -786,69 +691,22 @@ export default function ReimbursementManagementPortal() {
|
||||||
className="sticky top-0 lg:top-4 z-10 bg-base-100 p-3 sm:p-5 rounded-xl shadow-lg border border-base-300"
|
className="sticky top-0 lg:top-4 z-10 bg-base-100 p-3 sm:p-5 rounded-xl shadow-lg border border-base-300"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4 mb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4 mb-4">
|
||||||
<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-linear-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||||
Reimbursement Requests
|
Reimbursement Requests
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="badge badge-primary badge-md font-medium">
|
<span className="badge badge-primary badge-md font-medium">
|
||||||
{reimbursements.length} Total
|
{reimbursements.length} Total
|
||||||
</span>
|
</span>
|
||||||
{filters.hidePaid && (
|
|
||||||
<span className="badge badge-ghost badge-sm font-medium" title="Paid reimbursements are automatically hidden">
|
|
||||||
<Icon icon="heroicons:eye-slash" className="h-3 w-3 mr-1" />
|
|
||||||
Paid Hidden
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{filters.hideRejected && (
|
|
||||||
<span className="badge badge-ghost badge-sm font-medium" title="Rejected reimbursements are automatically hidden">
|
|
||||||
<Icon icon="heroicons:eye-slash" className="h-3 w-3 mr-1" />
|
|
||||||
Rejected Hidden
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
|
||||||
{/* Search Bar */}
|
|
||||||
<div className="form-control sm:col-span-2">
|
|
||||||
<div className="join h-9 relative">
|
|
||||||
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
|
||||||
<Icon icon="heroicons:magnifying-glass" className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={`input input-bordered input-sm w-full focus:outline-none h-full join-item rounded-l-none ${filters.search ? 'pr-16' : 'pr-8'}`}
|
|
||||||
placeholder="Search by title, user, date, receipt location..."
|
|
||||||
value={filters.search}
|
|
||||||
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
|
||||||
/>
|
|
||||||
{filters.search && (
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost btn-sm absolute right-2 top-0 h-full px-2"
|
|
||||||
onClick={() => setFilters(prev => ({ ...prev, search: '' }))}
|
|
||||||
>
|
|
||||||
<Icon icon="heroicons:x-mark" className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{filters.search && (
|
|
||||||
<div className="label py-1">
|
|
||||||
<span className="label-text-alt text-info">
|
|
||||||
<Icon icon="heroicons:information-circle" className="h-3 w-3 inline mr-1" />
|
|
||||||
Search includes all reimbursements (including paid/rejected)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Filter */}
|
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<div className="join h-9 relative">
|
<div className="join h-9 relative">
|
||||||
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
||||||
<Icon icon="heroicons:funnel" className="h-4 w-4" />
|
<Icon icon="heroicons:funnel" className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
className={`select select-bordered select-sm w-full focus:outline-none h-full join-item rounded-l-none ${filters.status.length > 0 ? 'pr-16' : 'pr-8'}`}
|
className={`select select-bordered select-sm w-full focus:outline-hidden h-full join-item rounded-l-none ${filters.status.length > 0 ? 'pr-16' : 'pr-8'}`}
|
||||||
value="placeholder"
|
value="placeholder"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
|
@ -897,14 +755,13 @@ 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">
|
||||||
<Icon icon="heroicons:building-office" className="h-4 w-4" />
|
<Icon icon="heroicons:building-office" className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
className={`select select-bordered select-sm w-full focus:outline-none h-full join-item rounded-l-none ${filters.department.length > 0 ? 'pr-16' : 'pr-8'}`}
|
className={`select select-bordered select-sm w-full focus:outline-hidden h-full join-item rounded-l-none ${filters.department.length > 0 ? 'pr-16' : 'pr-8'}`}
|
||||||
value="placeholder"
|
value="placeholder"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
|
@ -950,14 +807,13 @@ 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">
|
||||||
<Icon icon="heroicons:calendar" className="h-4 w-4" />
|
<Icon icon="heroicons:calendar" className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
className="select select-bordered select-sm w-full focus:outline-none h-full join-item rounded-l-none"
|
className="select select-bordered select-sm w-full focus:outline-hidden h-full join-item rounded-l-none"
|
||||||
value={filters.dateRange}
|
value={filters.dateRange}
|
||||||
onChange={(e) => setFilters({ ...filters, dateRange: e.target.value as FilterOptions['dateRange'] })}
|
onChange={(e) => setFilters({ ...filters, dateRange: e.target.value as FilterOptions['dateRange'] })}
|
||||||
>
|
>
|
||||||
|
@ -969,14 +825,13 @@ export default function ReimbursementManagementPortal() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort Controls */}
|
<div className="form-control md:col-span-2">
|
||||||
<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" />
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
className="select select-bordered select-sm w-full focus:outline-none h-full join-item rounded-none"
|
className="select select-bordered select-sm w-full focus:outline-hidden h-full join-item rounded-none"
|
||||||
value={filters.sortBy}
|
value={filters.sortBy}
|
||||||
onChange={(e) => setFilters({ ...filters, sortBy: e.target.value as FilterOptions['sortBy'] })}
|
onChange={(e) => setFilters({ ...filters, sortBy: e.target.value as FilterOptions['sortBy'] })}
|
||||||
>
|
>
|
||||||
|
@ -996,54 +851,6 @@ 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 ? (
|
||||||
|
@ -1067,7 +874,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<div className={`${filters.compactView ? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2' : 'space-y-4'}`}>
|
<div className="space-y-4">
|
||||||
{reimbursements.map((reimbursement, index) => (
|
{reimbursements.map((reimbursement, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={reimbursement.id}
|
key={reimbursement.id}
|
||||||
|
@ -1078,34 +885,6 @@ export default function ReimbursementManagementPortal() {
|
||||||
${selectedReimbursement?.id === reimbursement.id ? 'ring-2 ring-primary shadow-lg scale-[1.02]' : 'hover:scale-[1.01] hover:shadow-md'}`}
|
${selectedReimbursement?.id === reimbursement.id ? 'ring-2 ring-primary shadow-lg scale-[1.02]' : 'hover:scale-[1.01] hover:shadow-md'}`}
|
||||||
onClick={() => setSelectedReimbursement(reimbursement)}
|
onClick={() => setSelectedReimbursement(reimbursement)}
|
||||||
>
|
>
|
||||||
{filters.compactView ? (
|
|
||||||
// Compact Grid View
|
|
||||||
<div className="card-body p-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="font-semibold text-sm group-hover:text-primary transition-colors line-clamp-2 leading-tight">
|
|
||||||
{reimbursement.title}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center justify-between text-xs text-base-content/70">
|
|
||||||
<span>{new Date(reimbursement.date_of_purchase).toLocaleDateString()}</span>
|
|
||||||
<span className="font-mono font-bold text-primary text-sm">
|
|
||||||
${reimbursement.total_amount.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<span className={`badge badge-sm ${reimbursement.status === 'approved' ? 'badge-success' :
|
|
||||||
reimbursement.status === 'rejected' ? 'badge-error' :
|
|
||||||
reimbursement.status === 'under_review' ? 'badge-info' :
|
|
||||||
reimbursement.status === 'in_progress' ? 'badge-warning' :
|
|
||||||
reimbursement.status === 'paid' ? 'badge-success' :
|
|
||||||
'badge-ghost'
|
|
||||||
} capitalize font-medium whitespace-nowrap`}>
|
|
||||||
{reimbursement.status.replace('_', ' ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Regular View
|
|
||||||
<div className="card-body p-5">
|
<div className="card-body p-5">
|
||||||
<div className="flex justify-between items-start gap-4">
|
<div className="flex justify-between items-start gap-4">
|
||||||
<div className="space-y-2 flex-1 min-w-0">
|
<div className="space-y-2 flex-1 min-w-0">
|
||||||
|
@ -1114,16 +893,16 @@ export default function ReimbursementManagementPortal() {
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-3 text-sm">
|
<div className="flex flex-wrap gap-3 text-sm">
|
||||||
<div className="flex items-center gap-1.5 text-base-content/70">
|
<div className="flex items-center gap-1.5 text-base-content/70">
|
||||||
<Icon icon="heroicons:calendar" className="h-4 w-4 flex-shrink-0" />
|
<Icon icon="heroicons:calendar" className="h-4 w-4 shrink-0" />
|
||||||
<span>{new Date(reimbursement.date_of_purchase).toLocaleDateString()}</span>
|
<span>{new Date(reimbursement.date_of_purchase).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-base-content/70">
|
<div className="flex items-center gap-1.5 text-base-content/70">
|
||||||
<Icon icon="heroicons:building-office" className="h-4 w-4 flex-shrink-0" />
|
<Icon icon="heroicons:building-office" className="h-4 w-4 shrink-0" />
|
||||||
<span className="truncate">{reimbursement.department}</span>
|
<span className="truncate">{reimbursement.department}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||||
<span className="font-mono font-bold text-lg text-primary whitespace-nowrap">
|
<span className="font-mono font-bold text-lg text-primary whitespace-nowrap">
|
||||||
${reimbursement.total_amount.toFixed(2)}
|
${reimbursement.total_amount.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
|
@ -1141,13 +920,12 @@ export default function ReimbursementManagementPortal() {
|
||||||
reimbursement.status === 'in_progress' ? 'heroicons:currency-dollar' :
|
reimbursement.status === 'in_progress' ? 'heroicons:currency-dollar' :
|
||||||
reimbursement.status === 'paid' ? 'heroicons:banknotes' :
|
reimbursement.status === 'paid' ? 'heroicons:banknotes' :
|
||||||
'heroicons:clock'
|
'heroicons:clock'
|
||||||
} className="h-4 w-4 flex-shrink-0" />
|
} className="h-4 w-4 shrink-0" />
|
||||||
{reimbursement.status.replace('_', ' ')}
|
{reimbursement.status.replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1180,11 +958,11 @@ export default function ReimbursementManagementPortal() {
|
||||||
<img
|
<img
|
||||||
src={getUserAvatarUrl(selectedReimbursement.submitter)}
|
src={getUserAvatarUrl(selectedReimbursement.submitter)}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-6 h-6 rounded-full flex-shrink-0"
|
className="w-6 h-6 rounded-full shrink-0"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="font-medium text-base-content truncate">{selectedReimbursement.submitter?.name || 'Unknown User'}</span>
|
<span className="font-medium text-base-content truncate">{selectedReimbursement.submitter?.name || 'Unknown User'}</span>
|
||||||
<Icon icon="heroicons:chevron-down" className="h-4 w-4 flex-shrink-0" />
|
<Icon icon="heroicons:chevron-down" className="h-4 w-4 shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
{showUserProfile === selectedReimbursement.submitted_by && (
|
{showUserProfile === selectedReimbursement.submitted_by && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
@ -1210,7 +988,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
<div className="pt-2 border-t border-base-300">
|
<div className="pt-2 border-t border-base-300">
|
||||||
<h4 className="text-sm font-medium text-base-content/70 mb-1">Zelle Information</h4>
|
<h4 className="text-sm font-medium text-base-content/70 mb-1">Zelle Information</h4>
|
||||||
<p className="text-sm flex items-center gap-2">
|
<p className="text-sm flex items-center gap-2">
|
||||||
<Icon icon="heroicons:banknotes" className="h-4 w-4 text-primary flex-shrink-0" />
|
<Icon icon="heroicons:banknotes" className="h-4 w-4 text-primary shrink-0" />
|
||||||
{selectedReimbursement.submitter.zelle_information}
|
{selectedReimbursement.submitter.zelle_information}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1231,7 +1009,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
{loadingStatus ? (
|
{loadingStatus ? (
|
||||||
<span className="loading loading-spinner loading-sm" />
|
<span className="loading loading-spinner loading-sm" />
|
||||||
) : (
|
) : (
|
||||||
<Icon icon="heroicons:eye" className="h-4 w-4 flex-shrink-0" />
|
<Icon icon="heroicons:eye" className="h-4 w-4 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="font-medium">Review</span>
|
<span className="font-medium">Review</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -1246,7 +1024,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
{loadingStatus ? (
|
{loadingStatus ? (
|
||||||
<span className="loading loading-spinner loading-sm" />
|
<span className="loading loading-spinner loading-sm" />
|
||||||
) : (
|
) : (
|
||||||
<Icon icon="heroicons:check" className="h-4 w-4 flex-shrink-0" />
|
<Icon icon="heroicons:check" className="h-4 w-4 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="font-medium">Approve</span>
|
<span className="font-medium">Approve</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -1260,7 +1038,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
{loadingStatus ? (
|
{loadingStatus ? (
|
||||||
<span className="loading loading-spinner loading-sm" />
|
<span className="loading loading-spinner loading-sm" />
|
||||||
) : (
|
) : (
|
||||||
<Icon icon="heroicons:currency-dollar" className="h-4 w-4 flex-shrink-0" />
|
<Icon icon="heroicons:currency-dollar" className="h-4 w-4 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="font-medium">Mark as in progress</span>
|
<span className="font-medium">Mark as in progress</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -1274,7 +1052,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
{loadingStatus ? (
|
{loadingStatus ? (
|
||||||
<span className="loading loading-spinner loading-sm" />
|
<span className="loading loading-spinner loading-sm" />
|
||||||
) : (
|
) : (
|
||||||
<Icon icon="heroicons:check-circle" className="h-4 w-4 flex-shrink-0" />
|
<Icon icon="heroicons:check-circle" className="h-4 w-4 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="font-medium">Mark as Paid</span>
|
<span className="font-medium">Mark as Paid</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -1288,7 +1066,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
{loadingStatus ? (
|
{loadingStatus ? (
|
||||||
<span className="loading loading-spinner loading-sm" />
|
<span className="loading loading-spinner loading-sm" />
|
||||||
) : (
|
) : (
|
||||||
<Icon icon="heroicons:x-mark" className="h-4 w-4 flex-shrink-0" />
|
<Icon icon="heroicons:x-mark" className="h-4 w-4 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="font-medium">Reject</span>
|
<span className="font-medium">Reject</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -1304,34 +1082,34 @@ export default function ReimbursementManagementPortal() {
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xs:grid-cols-2 gap-2 sm:gap-3">
|
<div className="grid grid-cols-1 xs:grid-cols-2 gap-2 sm:gap-3">
|
||||||
<div className="card bg-base-200 hover:bg-base-300 transition-colors">
|
<div className="card bg-base-200 hover:bg-base-300 transition-colors">
|
||||||
<div className="card-body !p-3">
|
<div className="card-body p-3!">
|
||||||
<h3 className="text-sm font-medium text-base-content/70">Date of Purchase</h3>
|
<h3 className="text-sm font-medium text-base-content/70">Date of Purchase</h3>
|
||||||
<p className="flex items-center gap-2 font-medium mt-1">
|
<p className="flex items-center gap-2 font-medium mt-1">
|
||||||
<Icon icon="heroicons:calendar" className="h-4 w-4 text-primary flex-shrink-0" />
|
<Icon icon="heroicons:calendar" className="h-4 w-4 text-primary shrink-0" />
|
||||||
{new Date(selectedReimbursement.date_of_purchase).toLocaleDateString()}
|
{new Date(selectedReimbursement.date_of_purchase).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card bg-base-200 hover:bg-base-300 transition-colors">
|
<div className="card bg-base-200 hover:bg-base-300 transition-colors">
|
||||||
<div className="card-body !p-3">
|
<div className="card-body p-3!">
|
||||||
<h3 className="text-sm font-medium text-base-content/70">Payment Method</h3>
|
<h3 className="text-sm font-medium text-base-content/70">Payment Method</h3>
|
||||||
<p className="flex items-center gap-2 font-medium mt-1">
|
<p className="flex items-center gap-2 font-medium mt-1">
|
||||||
<Icon icon="heroicons:credit-card" className="h-4 w-4 text-primary flex-shrink-0" />
|
<Icon icon="heroicons:credit-card" className="h-4 w-4 text-primary shrink-0" />
|
||||||
{selectedReimbursement.payment_method}
|
{selectedReimbursement.payment_method}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card bg-base-200 hover:bg-base-300 transition-colors">
|
<div className="card bg-base-200 hover:bg-base-300 transition-colors">
|
||||||
<div className="card-body !p-3">
|
<div className="card-body p-3!">
|
||||||
<h3 className="text-sm font-medium text-base-content/70">Department</h3>
|
<h3 className="text-sm font-medium text-base-content/70">Department</h3>
|
||||||
<p className="flex items-center gap-2 font-medium mt-1">
|
<p className="flex items-center gap-2 font-medium mt-1">
|
||||||
<Icon icon="heroicons:building-office" className="h-4 w-4 text-primary flex-shrink-0" />
|
<Icon icon="heroicons:building-office" className="h-4 w-4 text-primary shrink-0" />
|
||||||
<span className="capitalize">{selectedReimbursement.department.replace('_', ' ')}</span>
|
<span className="capitalize">{selectedReimbursement.department.replace('_', ' ')}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card bg-base-200 hover:bg-base-300 transition-colors">
|
<div className="card bg-base-200 hover:bg-base-300 transition-colors">
|
||||||
<div className="card-body !p-3">
|
<div className="card-body p-3!">
|
||||||
<h3 className="text-sm font-medium text-base-content/70">Total Amount</h3>
|
<h3 className="text-sm font-medium text-base-content/70">Total Amount</h3>
|
||||||
<p className="font-mono font-bold text-xl text-primary">
|
<p className="font-mono font-bold text-xl text-primary">
|
||||||
${selectedReimbursement.total_amount.toFixed(2)}
|
${selectedReimbursement.total_amount.toFixed(2)}
|
||||||
|
@ -1340,10 +1118,10 @@ export default function ReimbursementManagementPortal() {
|
||||||
</div>
|
</div>
|
||||||
{selectedReimbursement.submitter?.zelle_information && (
|
{selectedReimbursement.submitter?.zelle_information && (
|
||||||
<div className="card bg-base-200 hover:bg-base-300 transition-colors xs:col-span-2">
|
<div className="card bg-base-200 hover:bg-base-300 transition-colors xs:col-span-2">
|
||||||
<div className="card-body !p-3">
|
<div className="card-body p-3!">
|
||||||
<h3 className="text-sm font-medium text-base-content/70">Zelle Information</h3>
|
<h3 className="text-sm font-medium text-base-content/70">Zelle Information</h3>
|
||||||
<p className="flex items-center gap-2 font-medium mt-1">
|
<p className="flex items-center gap-2 font-medium mt-1">
|
||||||
<Icon icon="heroicons:banknotes" className="h-4 w-4 text-primary flex-shrink-0" />
|
<Icon icon="heroicons:banknotes" className="h-4 w-4 text-primary shrink-0" />
|
||||||
{selectedReimbursement.submitter.zelle_information}
|
{selectedReimbursement.submitter.zelle_information}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1375,7 +1153,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="card bg-base-200 hover:bg-base-300 transition-all duration-200"
|
className="card bg-base-200 hover:bg-base-300 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<div className="card-body !p-3 sm:!p-4">
|
<div className="card-body p-3! sm:p-4!">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start gap-2 sm:gap-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start gap-2 sm:gap-4">
|
||||||
<div className="space-y-1 flex-1 min-w-0">
|
<div className="space-y-1 flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-base sm:text-lg truncate">
|
<h3 className="font-semibold text-base sm:text-lg truncate">
|
||||||
|
@ -1383,11 +1161,11 @@ export default function ReimbursementManagementPortal() {
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-base-content/70">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-base-content/70">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Icon icon="heroicons:map-pin" className="h-4 w-4 flex-shrink-0" />
|
<Icon icon="heroicons:map-pin" className="h-4 w-4 shrink-0" />
|
||||||
<span className="truncate">{receipt.location_address}</span>
|
<span className="truncate">{receipt.location_address}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Icon icon="heroicons:calendar" className="h-4 w-4 flex-shrink-0" />
|
<Icon icon="heroicons:calendar" className="h-4 w-4 shrink-0" />
|
||||||
<span>{new Date(receipt.date).toLocaleDateString()}</span>
|
<span>{new Date(receipt.date).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -16,7 +16,7 @@ export const Button: React.FC<ButtonProps> = ({
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// Base classes
|
// Base classes
|
||||||
const baseClasses = 'font-medium rounded-md focus:outline-none transition-colors';
|
const baseClasses = 'font-medium rounded-md focus:outline-hidden transition-colors';
|
||||||
|
|
||||||
// Size classes
|
// Size classes
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
|
|
|
@ -79,7 +79,7 @@ const CustomAlert: React.FC<CustomAlertProps> = ({
|
||||||
<div className={`${color.bg} border-l-4 ${color.border} p-4 rounded-lg shadow-md ${className}`}>
|
<div className={`${color.bg} border-l-4 ${color.border} p-4 rounded-lg shadow-md ${className}`}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<div className="flex-shrink-0 mt-0.5">
|
<div className="shrink-0 mt-0.5">
|
||||||
<div className={`p-1.5 ${color.iconBg} rounded-full`}>
|
<div className={`p-1.5 ${color.iconBg} rounded-full`}>
|
||||||
<Icon
|
<Icon
|
||||||
icon={selectedIcon}
|
icon={selectedIcon}
|
||||||
|
@ -100,7 +100,7 @@ const CustomAlert: React.FC<CustomAlertProps> = ({
|
||||||
{actionLabel && onAction && (
|
{actionLabel && onAction && (
|
||||||
<button
|
<button
|
||||||
onClick={onAction}
|
onClick={onAction}
|
||||||
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${color.actionBg} ${color.actionHover} focus:outline-none focus:ring-2 focus:ring-offset-2 ${color.actionRing} transition-colors duration-200`}
|
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-xs text-white ${color.actionBg} ${color.actionHover} focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${color.actionRing} transition-colors duration-200`}
|
||||||
>
|
>
|
||||||
{actionLabel}
|
{actionLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -104,7 +104,7 @@ export default function ThemeToggle() {
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52 mt-2 text-xs">
|
<div className="dropdown-content z-1 menu p-2 shadow-sm bg-base-100 rounded-box w-52 mt-2 text-xs">
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<p className="font-bold text-warning 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>
|
||||||
|
|
|
@ -61,7 +61,7 @@ export const Toast: React.FC<ToastProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-4 right-4 z-50 animate-fade-in">
|
<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={`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">
|
<div className="inline-flex items-center justify-center shrink-0 w-8 h-8 mr-3">
|
||||||
{icons[type]}
|
{icons[type]}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 text-sm font-medium">{message}</div>
|
<div className="ml-3 text-sm font-medium">{message}</div>
|
||||||
|
|
|
@ -1,156 +0,0 @@
|
||||||
import React, { useState, useRef, useCallback } from 'react';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import FilePreview from './FilePreview';
|
|
||||||
|
|
||||||
interface ZoomablePreviewProps {
|
|
||||||
url: string;
|
|
||||||
filename: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ZoomablePreview({ url, filename }: ZoomablePreviewProps) {
|
|
||||||
const [zoom, setZoom] = useState(1);
|
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const zoomLevels = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
|
||||||
const currentZoomIndex = zoomLevels.findIndex(level => Math.abs(level - zoom) < 0.01);
|
|
||||||
|
|
||||||
const handleZoomIn = useCallback(() => {
|
|
||||||
const nextIndex = Math.min(currentZoomIndex + 1, zoomLevels.length - 1);
|
|
||||||
setZoom(zoomLevels[nextIndex]);
|
|
||||||
}, [currentZoomIndex]);
|
|
||||||
|
|
||||||
const handleZoomOut = useCallback(() => {
|
|
||||||
const prevIndex = Math.max(currentZoomIndex - 1, 0);
|
|
||||||
setZoom(zoomLevels[prevIndex]);
|
|
||||||
}, [currentZoomIndex]);
|
|
||||||
|
|
||||||
const handleZoomReset = useCallback(() => {
|
|
||||||
setZoom(1);
|
|
||||||
setPosition({ x: 0, y: 0 });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
||||||
if (zoom > 1) {
|
|
||||||
setIsDragging(true);
|
|
||||||
setDragStart({
|
|
||||||
x: e.clientX - position.x,
|
|
||||||
y: e.clientY - position.y
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [zoom, position]);
|
|
||||||
|
|
||||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
||||||
if (isDragging && zoom > 1) {
|
|
||||||
setPosition({
|
|
||||||
x: e.clientX - dragStart.x,
|
|
||||||
y: e.clientY - dragStart.y
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isDragging, dragStart, zoom]);
|
|
||||||
|
|
||||||
const handleMouseUp = useCallback(() => {
|
|
||||||
setIsDragging(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const delta = e.deltaY > 0 ? -1 : 1;
|
|
||||||
const newZoomIndex = Math.max(0, Math.min(zoomLevels.length - 1, currentZoomIndex + delta));
|
|
||||||
setZoom(zoomLevels[newZoomIndex]);
|
|
||||||
|
|
||||||
// Reset position when zooming out to 100% or less
|
|
||||||
if (zoomLevels[newZoomIndex] <= 1) {
|
|
||||||
setPosition({ x: 0, y: 0 });
|
|
||||||
}
|
|
||||||
}, [currentZoomIndex]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative h-full">
|
|
||||||
{/* Zoom Controls */}
|
|
||||||
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2 bg-base-100/90 backdrop-blur-sm rounded-lg p-2 shadow-lg">
|
|
||||||
<motion.button
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.9 }}
|
|
||||||
className="btn btn-xs btn-ghost"
|
|
||||||
onClick={handleZoomIn}
|
|
||||||
disabled={currentZoomIndex >= zoomLevels.length - 1}
|
|
||||||
title="Zoom In"
|
|
||||||
>
|
|
||||||
<Icon icon="heroicons:plus" className="h-3 w-3" />
|
|
||||||
</motion.button>
|
|
||||||
|
|
||||||
<div className="text-xs text-center font-mono px-1">
|
|
||||||
{Math.round(zoom * 100)}%
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.button
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.9 }}
|
|
||||||
className="btn btn-xs btn-ghost"
|
|
||||||
onClick={handleZoomOut}
|
|
||||||
disabled={currentZoomIndex <= 0}
|
|
||||||
title="Zoom Out"
|
|
||||||
>
|
|
||||||
<Icon icon="heroicons:minus" className="h-3 w-3" />
|
|
||||||
</motion.button>
|
|
||||||
|
|
||||||
<motion.button
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.9 }}
|
|
||||||
className="btn btn-xs btn-ghost"
|
|
||||||
onClick={handleZoomReset}
|
|
||||||
disabled={zoom === 1 && position.x === 0 && position.y === 0}
|
|
||||||
title="Reset Zoom"
|
|
||||||
>
|
|
||||||
<Icon icon="heroicons:arrows-pointing-out" className="h-3 w-3" />
|
|
||||||
</motion.button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Zoom Indicator */}
|
|
||||||
{zoom !== 1 && (
|
|
||||||
<div className="absolute top-4 left-4 z-10 bg-primary/90 backdrop-blur-sm text-primary-content text-xs px-2 py-1 rounded">
|
|
||||||
{zoom > 1 ? 'Click and drag to pan' : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Preview Container */}
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="relative h-full overflow-hidden rounded-lg cursor-move"
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
onMouseLeave={handleMouseUp}
|
|
||||||
onWheel={handleWheel}
|
|
||||||
style={{
|
|
||||||
cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-full transition-transform duration-100"
|
|
||||||
style={{
|
|
||||||
transform: `scale(${zoom}) translate(${position.x / zoom}px, ${position.y / zoom}px)`,
|
|
||||||
transformOrigin: 'center center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="p-4 h-full">
|
|
||||||
<FilePreview
|
|
||||||
url={url}
|
|
||||||
filename={filename}
|
|
||||||
isModal={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Usage Hint */}
|
|
||||||
<div className="absolute bottom-4 left-4 z-10 text-xs text-base-content/50 bg-base-100/80 backdrop-blur-sm px-2 py-1 rounded">
|
|
||||||
Scroll to zoom • Click and drag to pan
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -247,7 +247,7 @@ const Calendar = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
|
||||||
>
|
>
|
||||||
{/* Hovering Calendar Header */}
|
{/* Hovering Calendar Header */}
|
||||||
<div className="flex justify-center mb-[2vw]">
|
<div className="flex justify-center mb-[2vw]">
|
||||||
<div className="bg-gradient-to-t from-ieee-blue-100/5 to-ieee-blue-100/25 rounded-[1.5vw] p-[1vw] backdrop-blur-sm w-[30vw] px-[2vw]">
|
<div className="bg-linear-to-t from-ieee-blue-100/5 to-ieee-blue-100/25 rounded-[1.5vw] p-[1vw] backdrop-blur-xs w-[30vw] px-[2vw]">
|
||||||
<div className="flex items-center gap-[3vw]">
|
<div className="flex items-center gap-[3vw]">
|
||||||
<button
|
<button
|
||||||
onClick={() => changeMonth(-1)}
|
onClick={() => changeMonth(-1)}
|
||||||
|
@ -269,7 +269,7 @@ const Calendar = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Calendar Body */}
|
{/* Main Calendar Body */}
|
||||||
<div className="bg-gradient-to-t from-ieee-blue-100/5 to-ieee-blue-100/25 rounded-[1.5vw] p-[1vw] relative">
|
<div className="bg-linear-to-t from-ieee-blue-100/5 to-ieee-blue-100/25 rounded-[1.5vw] p-[1vw] relative">
|
||||||
{/* Week Days Header */}
|
{/* Week Days Header */}
|
||||||
<div className="grid grid-cols-7 gap-[0.5vw] mb-[1vw]">
|
<div className="grid grid-cols-7 gap-[0.5vw] mb-[1vw]">
|
||||||
{weekDays.map((day, index) => (
|
{weekDays.map((day, index) => (
|
||||||
|
@ -308,7 +308,7 @@ const Calendar = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
|
||||||
{getEventsForDay(day).map((event, eventIndex) => (
|
{getEventsForDay(day).map((event, eventIndex) => (
|
||||||
<div
|
<div
|
||||||
key={eventIndex}
|
key={eventIndex}
|
||||||
className="text-[0.8vw] border border-gray-300 text-white p-[0.5vw] rounded truncate cursor-pointer hover:bg-white/10 transition-colors relative"
|
className="text-[0.8vw] border border-gray-300 text-white p-[0.5vw] rounded-sm truncate cursor-pointer hover:bg-white/10 transition-colors relative"
|
||||||
onMouseEnter={(e) => handleEventMouseEnter(event, e)}
|
onMouseEnter={(e) => handleEventMouseEnter(event, e)}
|
||||||
onMouseLeave={handleEventMouseLeave}
|
onMouseLeave={handleEventMouseLeave}
|
||||||
>
|
>
|
||||||
|
@ -325,7 +325,7 @@ const Calendar = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
|
||||||
{/* Tooltip */}
|
{/* Tooltip */}
|
||||||
{hoveredEvent && (
|
{hoveredEvent && (
|
||||||
<div
|
<div
|
||||||
className="fixed z-[9999] bg-ieee-blue-100 text-white p-[1vw] rounded-[0.5vw] shadow-xl border border-white/20 min-w-[15vw]"
|
className="fixed z-9999 bg-ieee-blue-100 text-white p-[1vw] rounded-[0.5vw] shadow-xl border border-white/20 min-w-[15vw]"
|
||||||
style={{
|
style={{
|
||||||
left: `${tooltipPosition.x + 15}px`,
|
left: `${tooltipPosition.x + 15}px`,
|
||||||
top: `${tooltipPosition.y + 15}px`,
|
top: `${tooltipPosition.y + 15}px`,
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { LiaDotCircle } from "react-icons/lia";
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="w-full md:pt-[5vw] pt-[10vw] flex justify-center relative">
|
<div class="w-full md:pt-[5vw] pt-[10vw] flex justify-center relative">
|
||||||
<div class="w-[45%] rounded-[2vw] aspect-[2/1] relative">
|
<div class="w-[45%] rounded-[2vw] aspect-2/1 relative">
|
||||||
<div
|
<div
|
||||||
id="event-skeleton"
|
id="event-skeleton"
|
||||||
class="skeleton absolute inset-0 rounded-[2vw] z-0"
|
class="skeleton absolute inset-0 rounded-[2vw] z-0"
|
||||||
|
|
|
@ -14,12 +14,12 @@ const { image, text, link, delay } = Astro.props;
|
||||||
<img
|
<img
|
||||||
src={image}
|
src={image}
|
||||||
alt="involvement background"
|
alt="involvement background"
|
||||||
class="opacity-70 aspect-[230/425] object-cover rounded-[2vw] group-hover:opacity-50 duration-300"
|
class="opacity-70 aspect-230/425 object-cover rounded-[2vw] group-hover:opacity-50 duration-300"
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
href={link}
|
href={link}
|
||||||
target={text === "H.A.R.D. HACK" ? "_blank" : "_self"}
|
target={text === "H.A.R.D. HACK" ? "_blank" : "_self"}
|
||||||
className="absolute top-0 md:w-[15vw] w-[25vw] pt-[5%] aspect-[230/425] flex flex-col justify-between"
|
className="absolute top-0 md:w-[15vw] w-[25vw] pt-[5%] aspect-230/425 flex flex-col justify-between"
|
||||||
>
|
>
|
||||||
<div class="w-full flex justify-end md:pr-[5%] pr-[2vw]">
|
<div class="w-full flex justify-end md:pr-[5%] pr-[2vw]">
|
||||||
<div
|
<div
|
||||||
|
@ -38,7 +38,7 @@ const { image, text, link, delay } = Astro.props;
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="px-[3%] text-white w-full bg-gradient-to-t from-black via-black to-transparent rounded-b-[2vw] pt-[20vw] pb-[3vw] md:pt-[30%] md:pb-[5%]"
|
class="px-[3%] text-white w-full bg-linear-to-t from-black via-black to-transparent rounded-b-[2vw] pt-[20vw] pb-[3vw] md:pt-[30%] md:pb-[5%]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="text-[2vw] md:text-[1.1vw] duration-300 flex w-full px-[3%] justify-between items-end"
|
class="text-[2vw] md:text-[1.1vw] duration-300 flex w-full px-[3%] justify-between items-end"
|
||||||
|
|
|
@ -9,7 +9,7 @@ import about4 from "../../images/about4.webp";
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex border-white/40 border-[0.1vw] rounded-[2vw] md:h-[43vw] h-[60vw] md:px-[10%] px-[6%] py-[3%] bg-gradient-to-t to-ieee-blue-100/30 via-ieee-black from-ieee-black"
|
class="flex border-white/40 border-[0.1vw] rounded-[2vw] md:h-[43vw] h-[60vw] md:px-[10%] px-[6%] py-[3%] bg-linear-to-t to-ieee-blue-100/30 via-ieee-black from-ieee-black"
|
||||||
>
|
>
|
||||||
<div class="w-1/2 flex justify-center pr-[5%] relative h-full">
|
<div class="w-1/2 flex justify-center pr-[5%] relative h-full">
|
||||||
<div class="relative w-[35vw]">
|
<div class="relative w-[35vw]">
|
||||||
|
@ -21,7 +21,7 @@ import about4 from "../../images/about4.webp";
|
||||||
<Image
|
<Image
|
||||||
src={about4}
|
src={about4}
|
||||||
alt="About image"
|
alt="About image"
|
||||||
class="absolute top-[5%] left-[14%] aspect-[399/491] object-cover w-[33vw] md:w-[25vw] rounded-[2vw]"
|
class="absolute top-[5%] left-[14%] aspect-399/491 object-cover w-[33vw] md:w-[25vw] rounded-[2vw]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Image
|
<Image
|
||||||
|
|
|
@ -6,10 +6,10 @@ const { title, text, link, number, delay } = Astro.props;
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-inview
|
data-inview
|
||||||
class={`animate-ease-in-out relative text-white flex flex-col items-center w-[30vw] md:w-[17vw] bg-gradient-to-b from-ieee-blue-100/40 to-ieee-black md:h-[33vw] h-[40vw] border-[0.1vw] border-white/40 rounded-[2vw] md:pt-[5%] pt-[15%] pb-[3%] in-view:animate-fade-down duration-500 animate-delay-${delay}`}
|
class={`animate-ease-in-out relative text-white flex flex-col items-center w-[30vw] md:w-[17vw] bg-linear-to-b from-ieee-blue-100/40 to-ieee-black md:h-[33vw] h-[40vw] border-[0.1vw] border-white/40 rounded-[2vw] md:pt-[5%] pt-[15%] pb-[3%] in-view:animate-fade-down duration-500 animate-delay-${delay}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="rounded-full aspect-square w-[7vw] md:w-[5.5vw] absolute bg-gradient-to-b from-ieee-blue-100 to-ieee-blue-300 -top-[10%] shadow-xl shadow-ieee-blue-300"
|
class="rounded-full aspect-square w-[7vw] md:w-[5.5vw] absolute bg-linear-to-b from-ieee-blue-100 to-ieee-blue-300 -top-[10%] shadow-xl shadow-ieee-blue-300"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-[2.7vw] md:text-[1.7vw] font-bold text-center">
|
<p class="text-[2.7vw] md:text-[1.7vw] font-bold text-center">
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { MdEmail } from "react-icons/md";
|
||||||
|
|
||||||
<div class="w-full flex justify-between space-x-2 pt-[2%]">
|
<div class="w-full flex justify-between space-x-2 pt-[2%]">
|
||||||
<div
|
<div
|
||||||
class="md:pt-[5%] pt-[6%] bg-gradient-to-t to-ieee-blue-100/30 p-[5vw] via-ieee-black from-ieee-black md:w-[53%] w-[60%] md:h-[45vw] h-[65vw] border-white/70 border-[0.1vw] rounded-[3vw]"
|
class="md:pt-[5%] pt-[6%] bg-linear-to-t to-ieee-blue-100/30 p-[5vw] via-ieee-black from-ieee-black md:w-[53%] w-[60%] md:h-[45vw] h-[65vw] border-white/70 border-[0.1vw] rounded-[3vw]"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
data-inview
|
data-inview
|
||||||
|
@ -60,7 +60,7 @@ import { MdEmail } from "react-icons/md";
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="md:w-[46%] w-[40%] md:h-[45vw] h-[65vw] border-white/70 border-[0.1vw] rounded-[3vw] bg-gradient-to-b to-ieee-blue-100/60 from-ieee-black"
|
class="md:w-[46%] w-[40%] md:h-[45vw] h-[65vw] border-white/70 border-[0.1vw] rounded-[3vw] bg-linear-to-b to-ieee-blue-100/60 from-ieee-black"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={landingimg}
|
src={landingimg}
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { Image } from "astro:assets";
|
||||||
Object.entries(annualProjects).map(([title, project], index) => (
|
Object.entries(annualProjects).map(([title, project], index) => (
|
||||||
<a
|
<a
|
||||||
href={project.url || "#"}
|
href={project.url || "#"}
|
||||||
class={`project-card group relative flex-1 rounded-[1.5vw] overflow-hidden transition-all duration-500 ease-in-out md:hover:flex-[2] cursor-pointer mb-[5vw] md:mb-0 ${index === 0 ? "expanded" : ""}`}
|
class={`project-card group relative flex-1 rounded-[1.5vw] overflow-hidden transition-all duration-500 ease-in-out md:hover:flex-2 cursor-pointer mb-[5vw] md:mb-0 ${index === 0 ? "expanded" : ""}`}
|
||||||
data-project={index + 1}
|
data-project={index + 1}
|
||||||
target={title === "Supercomputing" ? "_blank" : "_self"}
|
target={title === "Supercomputing" ? "_blank" : "_self"}
|
||||||
>
|
>
|
||||||
|
@ -23,24 +23,24 @@ import { Image } from "astro:assets";
|
||||||
alt={`${title} Project`}
|
alt={`${title} Project`}
|
||||||
width={668}
|
width={668}
|
||||||
height={990}
|
height={990}
|
||||||
class="opacity-70 w-full h-[50vw] md:h-full object-cover rounded-[1.5vw] aspect-[2/3] transition-transform duration-500 ease-in-out md:group-hover:scale-110"
|
class="opacity-70 w-full h-[50vw] md:h-full object-cover rounded-[1.5vw] aspect-2/3 transition-transform duration-500 ease-in-out md:group-hover:scale-110"
|
||||||
/>
|
/>
|
||||||
<div class="absolute flex items-end bottom-0 left-0 px-[5%] pb-[5%] md:pt-[17%] bg-gradient-to-b from-transparent to-black via-black rounded-b-[1.5vw] text-white z-10 w-full transition-transform duration-300 md:[.expanded_&]:pb-[5%]">
|
<div class="absolute flex items-end bottom-0 left-0 px-[5%] pb-[5%] md:pt-[17%] bg-linear-to-b from-transparent to-black via-black rounded-b-[1.5vw] text-white z-10 w-full transition-transform duration-300 md:in-[.expanded]:pb-[5%]">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<p class="py-[1.5%] px-[8%] w-fit border-[0.1vw] border-white rounded-full text-nowrap md:text-[1.2vw] text-[3vw] font-light mb-[5%]">
|
<p class="py-[1.5%] px-[8%] w-fit border-[0.1vw] border-white rounded-full text-nowrap md:text-[1.2vw] text-[3vw] font-light mb-[5%]">
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-[3vw] md:text-[1.3vw] block md:hidden md:[.expanded_&]:block transition-all duration-300 overflow-hidden mb-[3vw]">
|
<p class="text-[3vw] md:text-[1.3vw] block md:hidden md:in-[.expanded]:block transition-all duration-300 overflow-hidden mb-[3vw]">
|
||||||
{project.description}
|
{project.description}
|
||||||
</p>
|
</p>
|
||||||
<div class="w-full flex justify-end md:invisible visible md:[.expanded_&]:visible h-auto md:h-0 md:[.expanded_&]:h-auto">
|
<div class="w-full flex justify-end md:invisible visible md:in-[.expanded]:visible h-auto md:h-0 md:in-[.expanded]:h-auto">
|
||||||
<div class="flex items-center md:text-[1.3vw] text-[3vw] md:[.expanded_&]:mt-[5%]">
|
<div class="flex items-center md:text-[1.3vw] text-[3vw] md:in-[.expanded]:mt-[5%]">
|
||||||
more details
|
more details
|
||||||
<IoIosArrowDroprightCircle className="ml-[0.5vw] text-[3vw] md:text-[1.4vw]" />
|
<IoIosArrowDroprightCircle className="ml-[0.5vw] text-[3vw] md:text-[1.4vw]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GoArrowDownRight className="text-[3.2vw] [.expanded_&]:text-[0px] pt-[2%] hidden md:block" />
|
<GoArrowDownRight className="text-[3.2vw] in-[.expanded]:text-[0px] pt-[2%] hidden md:block" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white w-fit rounded-full aspect-square p-[0.5vw] text-ieee-black md:text-[2vw] text-[3vw] absolute top-[3%] right-[5%]">
|
<div class="bg-white w-fit rounded-full aspect-square p-[0.5vw] text-ieee-black md:text-[2vw] text-[3vw] absolute top-[3%] right-[5%]">
|
||||||
|
|
|
@ -31,7 +31,7 @@ import { IoIosArrowDroprightCircle } from "react-icons/io";
|
||||||
</Link>
|
</Link>
|
||||||
<div
|
<div
|
||||||
data-inview
|
data-inview
|
||||||
class="in-view:animate-fade-right md:w-[45%] w-[70%] text-[1.8vw] md:text-[1vw] font-semibold bg-white/50 backdrop-blur text-black absolute md:-bottom-[6%] -bottom-[15%] md:left-[15%] left-[5%] px-[1.5%] py-[1%] rounded-[1.5vw]"
|
class="in-view:animate-fade-right md:w-[45%] w-[70%] text-[1.8vw] md:text-[1vw] font-semibold bg-white/50 backdrop-blur-sm text-black absolute md:-bottom-[6%] -bottom-[15%] md:left-[15%] left-[5%] px-[1.5%] py-[1%] rounded-[1.5vw]"
|
||||||
>
|
>
|
||||||
<p class="md:text-[1.4vw] text-[2.2vw] mb-[2%]">Quarterly Project</p>
|
<p class="md:text-[1.4vw] text-[2.2vw] mb-[2%]">Quarterly Project</p>
|
||||||
<p>
|
<p>
|
||||||
|
|
|
@ -14,6 +14,6 @@ import qp from "../../images/qp2.jpg";
|
||||||
<Image
|
<Image
|
||||||
src={qp}
|
src={qp}
|
||||||
alt="board group photos"
|
alt="board group photos"
|
||||||
class="md:w-1/2 w-[65vw] rounded-[2vw] object-cover aspect-[2/1] opacity-85"
|
class="md:w-1/2 w-[65vw] rounded-[2vw] object-cover aspect-2/1 opacity-85"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,9 +14,9 @@ import join from "../../images/join.png";
|
||||||
src={join}
|
src={join}
|
||||||
alt="blue background"
|
alt="blue background"
|
||||||
<<<<<<< HEAD
|
<<<<<<< HEAD
|
||||||
class="absolute rounded-full object-cover aspect-[262/433] w-[54%] top-[5%] left-0 opacity-80"
|
class="absolute rounded-full object-cover aspect-262/433 w-[54%] top-[5%] left-0 opacity-80"
|
||||||
=======
|
=======
|
||||||
class="absolute rounded-full object-cover aspect-[262/433] w-[54%] md:top-[5%] top-[9%] left-0"
|
class="absolute rounded-full object-cover aspect-262/433 w-[54%] md:top-[5%] top-[9%] left-0"
|
||||||
>>>>>>> 08bcf09f9c08053ec40f1c3ae02bbed3a374fb83
|
>>>>>>> 08bcf09f9c08053ec40f1c3ae02bbed3a374fb83
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -24,7 +24,7 @@ import join from "../../images/join.png";
|
||||||
<div class="flex items-center md:text-[2.3vw] text-[4vw] mb-[5%]">
|
<div class="flex items-center md:text-[2.3vw] text-[4vw] mb-[5%]">
|
||||||
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
|
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
|
||||||
<p
|
<p
|
||||||
class="md:text-[2.3vw] text-[4vw] font-bold text-transparent bg-clip-text bg-gradient-to-r from-white to-ieee-yellow"
|
class="md:text-[2.3vw] text-[4vw] font-bold text-transparent bg-clip-text bg-linear-to-r from-white to-ieee-yellow"
|
||||||
>
|
>
|
||||||
How to join
|
How to join
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -12,7 +12,7 @@ import jonathan from "../../images/about3.webp";
|
||||||
<div class="flex items-center md:text-[2.3vw] text-[4vw] mb-[5%]">
|
<div class="flex items-center md:text-[2.3vw] text-[4vw] mb-[5%]">
|
||||||
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
|
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
|
||||||
<p
|
<p
|
||||||
class="md:text-[2.3vw] text-[4vw] font-bold text-transparent bg-clip-text bg-gradient-to-l from-white to-ieee-yellow"
|
class="md:text-[2.3vw] text-[4vw] font-bold text-transparent bg-clip-text bg-linear-to-l from-white to-ieee-yellow"
|
||||||
>
|
>
|
||||||
Mentorship
|
Mentorship
|
||||||
</p>
|
</p>
|
||||||
|
@ -45,7 +45,7 @@ import jonathan from "../../images/about3.webp";
|
||||||
<Image
|
<Image
|
||||||
src={jonathan}
|
src={jonathan}
|
||||||
alt="blue background"
|
alt="blue background"
|
||||||
class="absolute rounded-full object-cover aspect-[262/433] w-[54%] md:top-[5%] top-[15%] right-[10%]"
|
class="absolute rounded-full object-cover aspect-262/433 w-[54%] md:top-[5%] top-[15%] right-[10%]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -16,7 +16,7 @@ import Link from "next/link";
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-0 px-[3%] w-full bg-gradient-to-t from-black via-black to-transparent rounded-b-[1.5vw] pb-[1vw] pt-[5%]"
|
class="absolute bottom-0 px-[3%] w-full bg-linear-to-t from-black via-black to-transparent rounded-b-[1.5vw] pb-[1vw] pt-[5%]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="text-[1.4vw] md:text-[1.1vw] flex w-full px-[2%] justify-between items-end"
|
class="text-[1.4vw] md:text-[1.1vw] flex w-full px-[2%] justify-between items-end"
|
||||||
|
|
|
@ -3,7 +3,7 @@ const { text, number, col, position, width } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`relative col-span-${col} border-[0.1vw] border-white/30 text-[1.5vw] md:text-[1.2vw] bg-gradient-to-b from-white/10 to-ieee-blue-300/10 px-[1vw] py-[2vw] rounded-[1vw] `}
|
class={`relative col-span-${col} border-[0.1vw] border-white/30 text-[1.5vw] md:text-[1.2vw] bg-linear-to-b from-white/10 to-ieee-blue-300/10 px-[1vw] py-[2vw] rounded-[1vw] `}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
class={`${position} text-[4vw] opacity-10 font-bold absolute leading-tight `}
|
class={`${position} text-[4vw] opacity-10 font-bold absolute leading-tight `}
|
||||||
|
|
|
@ -9,12 +9,12 @@ import robocup from "../../images/robocup.webp";
|
||||||
<Image
|
<Image
|
||||||
src={robocup}
|
src={robocup}
|
||||||
alt="robocub competition image"
|
alt="robocub competition image"
|
||||||
class="md:w-3/5 w-[90%] rounded-[2vw] object-cover aspect-[8/5] opacity-40"
|
class="md:w-3/5 w-[90%] rounded-[2vw] object-cover aspect-8/5 opacity-40"
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
href="https://docs.google.com/forms/d/e/1FAIpQLSex5VejEiClvgcfhSBQJ9IH5Q008j-HWC5Y9YAa56yIHgGBvw/viewform?usp=sf_link"
|
href="https://docs.google.com/forms/d/e/1FAIpQLSex5VejEiClvgcfhSBQJ9IH5Q008j-HWC5Y9YAa56yIHgGBvw/viewform?usp=sf_link"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="absolute aspect-[8/5] md:w-3/5 w-[90%] p-[5%] group -bottom-[3%]"
|
className="absolute aspect-8/5 md:w-3/5 w-[90%] p-[5%] group -bottom-[3%]"
|
||||||
>
|
>
|
||||||
<div class="w-full flex justify-end items-center h-4/5">
|
<div class="w-full flex justify-end items-center h-4/5">
|
||||||
<p class="md:text-[4.5vw] text-[7vw] font-bold w-fit">
|
<p class="md:text-[4.5vw] text-[7vw] font-bold w-fit">
|
||||||
|
|
|
@ -47,7 +47,7 @@ const centerIndex = Math.floor(subteams.length); // center in the middle
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="relative w-full h-full backdrop-blur-md overflow-hidden rounded-[2vw]">
|
<div class="relative w-full h-full backdrop-blur-md overflow-hidden rounded-[2vw]">
|
||||||
<div class="px-[8%] flex flex-col justify-center items-center md:w-[22vw] w-[25vw] md:h-[24vw] h-[36vw] bg-gradient-to-b from-ieee-blue-100/25 to-ieee-black backdrop-blur rounded-[2vw] border-white/40 border-[0.1vw]">
|
<div class="px-[8%] flex flex-col justify-center items-center md:w-[22vw] w-[25vw] md:h-[24vw] h-[36vw] bg-linear-to-b from-ieee-blue-100/25 to-ieee-black backdrop-blur-sm rounded-[2vw] border-white/40 border-[0.1vw]">
|
||||||
<p class="md:text-[1.5vw] text-[2vw] mb-[10%] font-semibold pt-[10%]">
|
<p class="md:text-[1.5vw] text-[2vw] mb-[10%] font-semibold pt-[10%]">
|
||||||
{subteam.title}
|
{subteam.title}
|
||||||
</p>
|
</p>
|
||||||
|
@ -60,7 +60,7 @@ const centerIndex = Math.floor(subteams.length); // center in the middle
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="-top-[10%] left-1/2 -translate-x-1/2 w-fit p-[5%] shadow-ieee-blue-300 md:text-[3.2vw] text-[4.5vw] bg-gradient-to-b from-ieee-blue-100 to-ieee-blue-300 rounded-full absolute">
|
<div class="-top-[10%] left-1/2 -translate-x-1/2 w-fit p-[5%] shadow-ieee-blue-300 md:text-[3.2vw] text-[4.5vw] bg-linear-to-b from-ieee-blue-100 to-ieee-blue-300 rounded-full absolute">
|
||||||
{subteam.title === "Mechanical" && (
|
{subteam.title === "Mechanical" && (
|
||||||
<FaGear />
|
<FaGear />
|
||||||
)}
|
)}
|
||||||
|
@ -80,14 +80,14 @@ const centerIndex = Math.floor(subteams.length); // center in the middle
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
id="prevBtn"
|
id="prevBtn"
|
||||||
class="absolute left-[-3vw] top-[calc(50%-1.5vw)] transform -translate-y-1/2 text-[3vw] text-white hover:text-white/70 duration-300 z-20 bg-black/30 hover:bg-black/50 p-2 rounded-full backdrop-blur-sm"
|
class="absolute left-[-3vw] top-[calc(50%-1.5vw)] transform -translate-y-1/2 text-[3vw] text-white hover:text-white/70 duration-300 z-20 bg-black/30 hover:bg-black/50 p-2 rounded-full backdrop-blur-xs"
|
||||||
aria-label="Previous card"
|
aria-label="Previous card"
|
||||||
>
|
>
|
||||||
<IoIosArrowBack />
|
<IoIosArrowBack />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id="nextBtn"
|
id="nextBtn"
|
||||||
class="absolute right-[-3vw] top-[calc(50%-1.5vw)] transform -translate-y-1/2 text-[3vw] text-white hover:text-white/70 duration-300 z-20 bg-black/30 hover:bg-black/50 p-2 rounded-full backdrop-blur-sm"
|
class="absolute right-[-3vw] top-[calc(50%-1.5vw)] transform -translate-y-1/2 text-[3vw] text-white hover:text-white/70 duration-300 z-20 bg-black/30 hover:bg-black/50 p-2 rounded-full backdrop-blur-xs"
|
||||||
aria-label="Next card"
|
aria-label="Next card"
|
||||||
>
|
>
|
||||||
<IoIosArrowForward />
|
<IoIosArrowForward />
|
||||||
|
|
|
@ -5,7 +5,7 @@ const {picture, name, description, link, linktext} = Astro.props;
|
||||||
---
|
---
|
||||||
<div class = "text-white relative my-[3%]" >
|
<div class = "text-white relative my-[3%]" >
|
||||||
|
|
||||||
<img src = {picture} alt = "signal" class = " w-full object-cover md:aspect-[1300/526] aspect-[2/1] opacity-25">
|
<img src = {picture} alt = "signal" class = " w-full object-cover md:aspect-1300/526 aspect-2/1 opacity-25">
|
||||||
|
|
||||||
<div class = "w-full flex justify-evenly absolute bottom-[20%] left-[4%]">
|
<div class = "w-full flex justify-evenly absolute bottom-[20%] left-[4%]">
|
||||||
<div data-inview class = "in-view:animate-fade-right flex items-center md:text-[2.5vw] text-[4vw]">
|
<div data-inview class = "in-view:animate-fade-right flex items-center md:text-[2.5vw] text-[4vw]">
|
||||||
|
|
|
@ -36,13 +36,6 @@ 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"
|
||||||
|
@ -117,7 +110,7 @@ categories:
|
||||||
|
|
||||||
officer:
|
officer:
|
||||||
title: "Officer Menu"
|
title: "Officer Menu"
|
||||||
sections: ["eventManagement", "officerEmailManagement", "eventRequestForm"]
|
sections: ["eventManagement", "eventRequestForm"]
|
||||||
role: "general"
|
role: "general"
|
||||||
|
|
||||||
executive:
|
executive:
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
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 "../styles/global.css";
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
|
@ -30,7 +31,7 @@ import InView from "../components/core/InView.astro";
|
||||||
<InView />
|
<InView />
|
||||||
<body class="w-full h-full m-0 bg-ieee-black">
|
<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">
|
||||||
|
|
|
@ -8,15 +8,15 @@ import Link from "next/link";
|
||||||
class="h-screen w-full flex justify-center items-center relative overflow-hidden"
|
class="h-screen w-full flex justify-center items-center relative overflow-hidden"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="animate-[pulse_3s_ease-in-out_infinite] bg-gradient-to-l to-indigo-950/80 from-ieee-blue-100/60 w-[100vw] h-[60vw] md:w-[43vw] md:h-[26vw] md:blur-[4vw] blur-[10vw] rounded-full absolute"
|
class="animate-[pulse_3s_ease-in-out_infinite] bg-linear-to-l to-indigo-950/80 from-ieee-blue-100/60 w-[100vw] h-[60vw] md:w-[43vw] md:h-[26vw] md:blur-[4vw] blur-[10vw] rounded-full absolute"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="animate-[pulse_4s_ease-in-out_infinite] bg-gradient-to-r from-blue-600/20 to-purple-600/20 w-[90vw] h-[55vw] md:w-[38vw] md:h-[24vw] md:blur-[5vw] blur-[11vw] rounded-full absolute -rotate-45"
|
class="animate-[pulse_4s_ease-in-out_infinite] bg-linear-to-r from-blue-600/20 to-purple-600/20 w-[90vw] h-[55vw] md:w-[38vw] md:h-[24vw] md:blur-[5vw] blur-[11vw] rounded-full absolute -rotate-45"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="animate-[pulse_5s_ease-in-out_infinite] bg-gradient-to-tr from-ieee-yellow/10 to-blue-500/10 w-[95vw] h-[58vw] md:w-[40vw] md:h-[25vw] md:blur-[4.5vw] blur-[10.5vw] rounded-full absolute rotate-45"
|
class="animate-[pulse_5s_ease-in-out_infinite] bg-linear-to-tr from-ieee-yellow/10 to-blue-500/10 w-[95vw] h-[58vw] md:w-[40vw] md:h-[25vw] md:blur-[4.5vw] blur-[10.5vw] rounded-full absolute rotate-45"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-center z-10">
|
<div class="flex flex-col items-center z-10">
|
||||||
|
@ -37,7 +37,7 @@ import Link from "next/link";
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="font-bold md:text-[1.2vw] text-[2.5vw] mt-[5%] text-slate-800/90 hover:text-ieee-yellow duration-300 select-none font-mono border-2 border-slate-800/90 rounded-lg px-8 py-3 hover:border-ieee-yellow flex items-center gap-3 group relative overflow-hidden bg-white/5 backdrop-blur-sm hover:bg-ieee-yellow/10 shadow-lg hover:shadow-ieee-yellow/20 transition-all"
|
className="font-bold md:text-[1.2vw] text-[2.5vw] mt-[5%] text-slate-800/90 hover:text-ieee-yellow duration-300 select-none font-mono border-2 border-slate-800/90 rounded-lg px-8 py-3 hover:border-ieee-yellow flex items-center gap-3 group relative overflow-hidden bg-white/5 backdrop-blur-xs hover:bg-ieee-yellow/10 shadow-lg hover:shadow-ieee-yellow/20 transition-all"
|
||||||
style={{ textShadow: "0vw 0vw 0.2vw #88BFEC" }}
|
style={{ textShadow: "0vw 0vw 0.2vw #88BFEC" }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
@ -57,7 +57,7 @@ import Link from "next/link";
|
||||||
<path d="M5 12h14m-7-7l7 7-7 7"></path>
|
<path d="M5 12h14m-7-7l7 7-7 7"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-gradient-to-r from-slate-800/0 via-slate-800/5 to-slate-800/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"
|
class="absolute inset-0 bg-linear-to-r from-slate-800/0 via-slate-800/5 to-slate-800/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -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@ieeeatucsd.org.
|
If you have any questions, please contact webmaster@ieeeucsd.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@ieeeatucsd.org
|
To: webmaster@ieeeucsd.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:
|
||||||
|
|
|
@ -1,120 +0,0 @@
|
||||||
import type { APIRoute } from 'astro';
|
|
||||||
import { initializeEmailServices, authenticatePocketBase } from '../../../scripts/email/EmailHelpers';
|
|
||||||
import {
|
|
||||||
sendEventRequestSubmissionEmail,
|
|
||||||
sendEventRequestStatusChangeEmail,
|
|
||||||
sendPRCompletedEmail,
|
|
||||||
sendDesignPRNotificationEmail
|
|
||||||
} from '../../../scripts/email/EventRequestEmailFunctions';
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
|
||||||
try {
|
|
||||||
console.log('📨 Event request email API called');
|
|
||||||
|
|
||||||
const {
|
|
||||||
type,
|
|
||||||
eventRequestId,
|
|
||||||
previousStatus,
|
|
||||||
newStatus,
|
|
||||||
changedByUserId,
|
|
||||||
declinedReason,
|
|
||||||
additionalContext,
|
|
||||||
authData
|
|
||||||
} = await request.json();
|
|
||||||
|
|
||||||
console.log('📋 Request data:', {
|
|
||||||
type,
|
|
||||||
eventRequestId,
|
|
||||||
hasAuthData: !!authData,
|
|
||||||
authDataHasToken: !!(authData?.token),
|
|
||||||
authDataHasModel: !!(authData?.model),
|
|
||||||
newStatus,
|
|
||||||
previousStatus
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!type || !eventRequestId) {
|
|
||||||
console.error('❌ Missing required parameters');
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Missing required parameters: type and eventRequestId' }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize services
|
|
||||||
const { pb, resend, fromEmail, replyToEmail } = await initializeEmailServices();
|
|
||||||
|
|
||||||
// Authenticate with PocketBase if auth data is provided
|
|
||||||
authenticatePocketBase(pb, authData);
|
|
||||||
|
|
||||||
let success = false;
|
|
||||||
|
|
||||||
console.log(`🎯 Processing event request email type: ${type}`);
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'event_request_submission':
|
|
||||||
success = await sendEventRequestSubmissionEmail(pb, resend, fromEmail, replyToEmail, {
|
|
||||||
eventRequestId
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'event_request_status_change':
|
|
||||||
if (!newStatus) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Missing newStatus for event request status change notification' }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
success = await sendEventRequestStatusChangeEmail(pb, resend, fromEmail, replyToEmail, {
|
|
||||||
eventRequestId,
|
|
||||||
newStatus,
|
|
||||||
previousStatus,
|
|
||||||
changedByUserId,
|
|
||||||
declinedReason: declinedReason || additionalContext?.declinedReason
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'pr_completed':
|
|
||||||
success = await sendPRCompletedEmail(pb, resend, fromEmail, replyToEmail, {
|
|
||||||
eventRequestId
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'design_pr_notification':
|
|
||||||
success = await sendDesignPRNotificationEmail(pb, resend, fromEmail, replyToEmail, {
|
|
||||||
eventRequestId,
|
|
||||||
action: additionalContext?.action || 'unknown'
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
console.error('❌ Unknown event request notification type:', type);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: `Unknown event request notification type: ${type}` }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📊 Event request email operation result: ${success ? 'SUCCESS' : 'FAILED'}`);
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success,
|
|
||||||
message: success ? 'Event request email notification sent successfully' : 'Failed to send event request email notification'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: success ? 200 : 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error in event request email notification API:', error);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Internal server error',
|
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
}),
|
|
||||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,155 +0,0 @@
|
||||||
import type { APIRoute } from 'astro';
|
|
||||||
import { OfficerEmailNotifications } from '../../../scripts/email/OfficerEmailNotifications';
|
|
||||||
import type { OfficerRoleChangeEmailData } from '../../../scripts/email/OfficerEmailNotifications';
|
|
||||||
import { initializeEmailServices, authenticatePocketBase } from '../../../scripts/email/EmailHelpers';
|
|
||||||
import { Collections } from '../../../schemas/pocketbase';
|
|
||||||
import type { User, Officer } from '../../../schemas/pocketbase/schema';
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
|
||||||
try {
|
|
||||||
console.log('📨 Officer notification email API called');
|
|
||||||
|
|
||||||
const requestData = await request.json();
|
|
||||||
const {
|
|
||||||
type,
|
|
||||||
officerId,
|
|
||||||
additionalContext,
|
|
||||||
authData
|
|
||||||
} = requestData;
|
|
||||||
|
|
||||||
console.log('📋 Request data:', {
|
|
||||||
type,
|
|
||||||
officerId,
|
|
||||||
hasAdditionalContext: !!additionalContext,
|
|
||||||
hasAuthData: !!authData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (type !== 'officer_role_change') {
|
|
||||||
console.error('❌ Invalid notification type for officer endpoint:', type);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: `Invalid notification type: ${type}` }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!officerId) {
|
|
||||||
console.error('❌ Missing required parameter: officerId');
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Missing required parameter: officerId' }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize services - this creates a fresh PocketBase instance for server-side use
|
|
||||||
const { pb } = await initializeEmailServices();
|
|
||||||
|
|
||||||
// Authenticate with PocketBase if auth data is provided
|
|
||||||
authenticatePocketBase(pb, authData);
|
|
||||||
|
|
||||||
const emailService = OfficerEmailNotifications.getInstance();
|
|
||||||
|
|
||||||
// Get the officer record with user data
|
|
||||||
console.log('🔍 Fetching officer data...');
|
|
||||||
const officer = await pb.collection(Collections.OFFICERS).getOne(officerId, {
|
|
||||||
expand: 'user'
|
|
||||||
}) as Officer & { expand?: { user: User } };
|
|
||||||
|
|
||||||
if (!officer) {
|
|
||||||
console.error('❌ Officer not found:', officerId);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Officer not found' }),
|
|
||||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the user data from the expanded relation
|
|
||||||
const user = officer.expand?.user;
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.error('❌ User data not found for officer:', officerId);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'User data not found for officer' }),
|
|
||||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract additional context data
|
|
||||||
const {
|
|
||||||
previousRole,
|
|
||||||
previousType,
|
|
||||||
newRole,
|
|
||||||
newType,
|
|
||||||
changedByUserId,
|
|
||||||
isNewOfficer
|
|
||||||
} = additionalContext || {};
|
|
||||||
|
|
||||||
// Get the name of the person who made the change
|
|
||||||
let changedByName = '';
|
|
||||||
if (changedByUserId) {
|
|
||||||
try {
|
|
||||||
const changedByUser = await pb.collection(Collections.USERS).getOne(changedByUserId) as User;
|
|
||||||
changedByName = changedByUser?.name || 'Unknown User';
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Could not fetch changed by user name:', error);
|
|
||||||
changedByName = 'Unknown User';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare email data
|
|
||||||
const emailData: OfficerRoleChangeEmailData = {
|
|
||||||
user,
|
|
||||||
officer,
|
|
||||||
previousRole,
|
|
||||||
previousType,
|
|
||||||
newRole: newRole || officer.role,
|
|
||||||
newType: newType || officer.type,
|
|
||||||
changedBy: changedByName,
|
|
||||||
isNewOfficer: isNewOfficer || false
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('📧 Sending officer role change notification...');
|
|
||||||
console.log('📧 Email data:', {
|
|
||||||
userName: user.name,
|
|
||||||
userEmail: user.email,
|
|
||||||
officerRole: emailData.newRole,
|
|
||||||
officerType: emailData.newType,
|
|
||||||
previousRole: emailData.previousRole,
|
|
||||||
previousType: emailData.previousType,
|
|
||||||
changedBy: emailData.changedBy,
|
|
||||||
isNewOfficer: emailData.isNewOfficer
|
|
||||||
});
|
|
||||||
|
|
||||||
const success = await emailService.sendRoleChangeNotification(emailData);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
console.log('✅ Officer role change notification sent successfully');
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
message: 'Officer role change notification sent successfully'
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error('❌ Failed to send officer role change notification');
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to send officer role change notification'
|
|
||||||
}),
|
|
||||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error in officer notification API:', error);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: 'Internal server error',
|
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
}),
|
|
||||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,910 +0,0 @@
|
||||||
import type { APIRoute } from 'astro';
|
|
||||||
import { initializeEmailServices, authenticatePocketBase, getStatusColor, getStatusText, getNextStepsText } from '../../../scripts/email/EmailHelpers';
|
|
||||||
|
|
||||||
// Add function to generate status image URL (now SVG-based)
|
|
||||||
function getStatusImageUrl(status: string, baseUrl: string = ''): string {
|
|
||||||
return `${baseUrl}/api/generate-status-image?status=${status}&width=500&height=150`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
|
||||||
try {
|
|
||||||
console.log('📨 Reimbursement email API called');
|
|
||||||
|
|
||||||
const {
|
|
||||||
type,
|
|
||||||
reimbursementId,
|
|
||||||
previousStatus,
|
|
||||||
newStatus,
|
|
||||||
changedByUserId,
|
|
||||||
comment,
|
|
||||||
commentByUserId,
|
|
||||||
isPrivate,
|
|
||||||
additionalContext,
|
|
||||||
authData,
|
|
||||||
useImageProgress = true // New option to use image instead of HTML progress (default: true for better email compatibility)
|
|
||||||
} = await request.json();
|
|
||||||
|
|
||||||
console.log('📋 Request data:', {
|
|
||||||
type,
|
|
||||||
reimbursementId,
|
|
||||||
hasAuthData: !!authData,
|
|
||||||
authDataHasToken: !!(authData?.token),
|
|
||||||
authDataHasModel: !!(authData?.model),
|
|
||||||
commentLength: comment?.length || 0,
|
|
||||||
commentByUserId,
|
|
||||||
isPrivate
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!type || !reimbursementId) {
|
|
||||||
console.error('❌ Missing required parameters');
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Missing required parameters: type and reimbursementId' }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize services
|
|
||||||
const { pb, resend, fromEmail, replyToEmail } = await initializeEmailServices();
|
|
||||||
|
|
||||||
// Authenticate with PocketBase if auth data is provided (skip for test emails)
|
|
||||||
if (type !== 'test') {
|
|
||||||
authenticatePocketBase(pb, authData);
|
|
||||||
}
|
|
||||||
|
|
||||||
let success = false;
|
|
||||||
|
|
||||||
console.log(`🎯 Processing reimbursement email type: ${type}`);
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'status_change':
|
|
||||||
if (!newStatus) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Missing newStatus for status_change notification' }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
success = await sendStatusChangeEmail(pb, resend, fromEmail, replyToEmail, {
|
|
||||||
reimbursementId,
|
|
||||||
newStatus,
|
|
||||||
previousStatus,
|
|
||||||
changedByUserId,
|
|
||||||
additionalContext,
|
|
||||||
useImageProgress
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'comment':
|
|
||||||
if (!comment || !commentByUserId) {
|
|
||||||
console.error('❌ Missing comment or commentByUserId for comment notification');
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Missing comment or commentByUserId for comment notification' }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
success = await sendCommentEmail(pb, resend, fromEmail, replyToEmail, {
|
|
||||||
reimbursementId,
|
|
||||||
comment,
|
|
||||||
commentByUserId,
|
|
||||||
isPrivate: isPrivate || false
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'submission':
|
|
||||||
success = await sendSubmissionEmail(pb, resend, fromEmail, replyToEmail, {
|
|
||||||
reimbursementId
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'test':
|
|
||||||
const { email } = additionalContext || {};
|
|
||||||
if (!email) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Missing email for test notification' }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
success = await sendTestEmail(resend, fromEmail, replyToEmail, email);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
console.error('❌ Unknown reimbursement notification type:', type);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: `Unknown reimbursement notification type: ${type}` }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📊 Reimbursement email operation result: ${success ? 'SUCCESS' : 'FAILED'}`);
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success,
|
|
||||||
message: success ? 'Reimbursement email notification sent successfully' : 'Failed to send reimbursement email notification'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: success ? 200 : 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error in reimbursement email notification API:', error);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Internal server error',
|
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
}),
|
|
||||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper functions for reimbursement email types
|
|
||||||
async function sendStatusChangeEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
console.log('📧 Starting reimbursement status change email process...');
|
|
||||||
console.log('Environment check:', {
|
|
||||||
hasResendKey: !!import.meta.env.RESEND_API_KEY,
|
|
||||||
fromEmail,
|
|
||||||
replyToEmail,
|
|
||||||
pocketbaseUrl: import.meta.env.POCKETBASE_URL
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if this is a test scenario
|
|
||||||
const isTestData = data.reimbursementId?.includes('test') || data.reimbursementId === 'test-id';
|
|
||||||
|
|
||||||
let reimbursement, user;
|
|
||||||
|
|
||||||
if (isTestData) {
|
|
||||||
console.log('🧪 Using test data for demonstration');
|
|
||||||
// Use mock data for testing
|
|
||||||
reimbursement = {
|
|
||||||
id: data.reimbursementId,
|
|
||||||
title: 'Test Reimbursement Request',
|
|
||||||
total_amount: 125.50,
|
|
||||||
date_of_purchase: new Date().toISOString(),
|
|
||||||
department: 'general',
|
|
||||||
payment_method: 'Personal Card',
|
|
||||||
status: data.previousStatus || 'submitted',
|
|
||||||
submitted_by: 'test-user-id',
|
|
||||||
audit_notes: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
user = {
|
|
||||||
id: 'test-user-id',
|
|
||||||
name: 'Test User',
|
|
||||||
email: data.additionalContext?.testEmail || 'test@example.com'
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('✅ Test data prepared:', {
|
|
||||||
reimbursementTitle: reimbursement.title,
|
|
||||||
userEmail: user.email
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Get real reimbursement details
|
|
||||||
console.log('🔍 Fetching reimbursement details for:', data.reimbursementId);
|
|
||||||
reimbursement = await pb.collection('reimbursement').getOne(data.reimbursementId);
|
|
||||||
console.log('✅ Reimbursement fetched:', { id: reimbursement.id, title: reimbursement.title });
|
|
||||||
|
|
||||||
// Get submitter user details
|
|
||||||
console.log('👤 Fetching user details for:', reimbursement.submitted_by);
|
|
||||||
user = await pb.collection('users').getOne(reimbursement.submitted_by);
|
|
||||||
if (!user || !user.email) {
|
|
||||||
console.error('❌ User not found or no email:', reimbursement.submitted_by);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
console.log('✅ User fetched:', { id: user.id, name: user.name, email: user.email });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get changed by user name if provided
|
|
||||||
let changedByName = 'System';
|
|
||||||
if (data.changedByUserId) {
|
|
||||||
try {
|
|
||||||
const changedByUser = await pb.collection('users').getOne(data.changedByUserId);
|
|
||||||
changedByName = changedByUser?.name || 'Unknown User';
|
|
||||||
console.log('👤 Changed by user:', changedByName);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('⚠️ Could not get changed by user name:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const subject = `Reimbursement Status Updated: ${reimbursement.title}`;
|
|
||||||
const statusColor = getStatusColor(data.newStatus);
|
|
||||||
const statusText = getStatusText(data.newStatus);
|
|
||||||
|
|
||||||
console.log('📝 Email details:', {
|
|
||||||
to: user.email,
|
|
||||||
subject,
|
|
||||||
status: data.newStatus
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add audit note when reimbursement is declined (skip for test data)
|
|
||||||
if (data.newStatus === 'rejected' && !isTestData) {
|
|
||||||
try {
|
|
||||||
console.log('📝 Adding audit note for declined reimbursement...');
|
|
||||||
|
|
||||||
// Prepare audit note content
|
|
||||||
let auditNote = `Status changed to REJECTED by ${changedByName}`;
|
|
||||||
if (data.additionalContext?.rejectionReason) {
|
|
||||||
auditNote += `\nRejection Reason: ${data.additionalContext.rejectionReason}`;
|
|
||||||
}
|
|
||||||
auditNote += `\nDate: ${new Date().toLocaleString()}`;
|
|
||||||
|
|
||||||
// Get existing audit notes or initialize empty string
|
|
||||||
const existingNotes = reimbursement.audit_notes || '';
|
|
||||||
const updatedNotes = existingNotes
|
|
||||||
? `${existingNotes}\n\n--- DECLINE RECORD ---\n${auditNote}`
|
|
||||||
: `--- DECLINE RECORD ---\n${auditNote}`;
|
|
||||||
|
|
||||||
// Update the reimbursement record with the new audit notes
|
|
||||||
await pb.collection('reimbursement').update(data.reimbursementId, {
|
|
||||||
audit_notes: updatedNotes
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ Audit note added successfully for declined reimbursement');
|
|
||||||
} catch (auditError) {
|
|
||||||
console.error('❌ Failed to add audit note for declined reimbursement:', auditError);
|
|
||||||
// Don't fail the entire email process if audit note fails
|
|
||||||
}
|
|
||||||
} else if (data.newStatus === 'rejected' && isTestData) {
|
|
||||||
console.log('🧪 Skipping audit note update for test data');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to generate status progress bar HTML (email-compatible)
|
|
||||||
function generateStatusProgressBar(currentStatus: string): string {
|
|
||||||
const statusOrder = ['submitted', 'under_review', 'approved', 'in_progress', 'paid'];
|
|
||||||
const rejectedStatus = ['submitted', 'under_review', 'rejected'];
|
|
||||||
|
|
||||||
const isRejected = currentStatus === 'rejected';
|
|
||||||
const statuses = isRejected ? rejectedStatus : statusOrder;
|
|
||||||
|
|
||||||
const statusIcons: Record<string, string> = {
|
|
||||||
submitted: '→',
|
|
||||||
under_review: '?',
|
|
||||||
approved: '✓',
|
|
||||||
rejected: '✗',
|
|
||||||
in_progress: '○',
|
|
||||||
paid: '$'
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
|
||||||
submitted: 'Submitted',
|
|
||||||
under_review: 'Under Review',
|
|
||||||
approved: 'Approved',
|
|
||||||
rejected: 'Rejected',
|
|
||||||
in_progress: 'In Progress',
|
|
||||||
paid: 'Paid'
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentIndex = statuses.indexOf(currentStatus);
|
|
||||||
|
|
||||||
let progressBarHtml = `
|
|
||||||
<div style="background: #f8fafc; padding: 30px 20px; border-radius: 8px; margin: 20px 0; border: 1px solid #e2e8f0;">
|
|
||||||
<h3 style="margin: 0 0 30px 0; color: #1e293b; font-size: 16px; font-weight: 600; text-align: center;">Request Progress</h3>
|
|
||||||
<table style="width: 100%; max-width: 500px; margin: 0 auto; border-collapse: collapse; position: relative;">
|
|
||||||
<tr style="position: relative;">
|
|
||||||
<td colspan="${statuses.length * 2 - 1}" style="height: 2px; background: #e2e8f0; position: absolute; top: 21px; left: 0; right: 0; z-index: 3;"></td>
|
|
||||||
</tr>
|
|
||||||
<tr style="position: relative; z-index: 1;">
|
|
||||||
`;
|
|
||||||
|
|
||||||
statuses.forEach((status, index) => {
|
|
||||||
const isActive = index <= currentIndex;
|
|
||||||
const isCurrent = status === currentStatus;
|
|
||||||
|
|
||||||
let backgroundColor, textColor, lineColor;
|
|
||||||
if (isCurrent) {
|
|
||||||
if (status === 'rejected') {
|
|
||||||
backgroundColor = '#ef4444';
|
|
||||||
textColor = 'white';
|
|
||||||
lineColor = '#ef4444';
|
|
||||||
} else if (status === 'paid') {
|
|
||||||
backgroundColor = '#10b981';
|
|
||||||
textColor = 'white';
|
|
||||||
lineColor = '#10b981';
|
|
||||||
} else if (status === 'in_progress') {
|
|
||||||
backgroundColor = '#f59e0b';
|
|
||||||
textColor = 'white';
|
|
||||||
lineColor = '#f59e0b';
|
|
||||||
} else {
|
|
||||||
backgroundColor = '#3b82f6';
|
|
||||||
textColor = 'white';
|
|
||||||
lineColor = '#3b82f6';
|
|
||||||
}
|
|
||||||
} else if (isActive) {
|
|
||||||
backgroundColor = '#e2e8f0';
|
|
||||||
textColor = '#475569';
|
|
||||||
lineColor = '#cbd5e1';
|
|
||||||
} else {
|
|
||||||
backgroundColor = '#f8fafc';
|
|
||||||
textColor = '#94a3b8';
|
|
||||||
lineColor = '#e2e8f0';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status circle
|
|
||||||
progressBarHtml += `
|
|
||||||
<td style="text-align: center; padding: 0; vertical-align: top; position: relative; width: ${100/statuses.length}%;">
|
|
||||||
<div style="position: relative; z-index: 1; padding: 5px 0;">
|
|
||||||
<div style="
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: ${backgroundColor};
|
|
||||||
color: ${textColor};
|
|
||||||
text-align: center;
|
|
||||||
line-height: 32px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
border: 3px solid #f8fafc;
|
|
||||||
box-shadow: none;
|
|
||||||
margin: 0 auto 8px auto;
|
|
||||||
">
|
|
||||||
${statusIcons[status]}
|
|
||||||
</div>
|
|
||||||
<div style="
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: ${isCurrent ? (status === 'rejected' ? '#ef4444' : status === 'paid' ? '#10b981' : status === 'in_progress' ? '#f59e0b' : '#3b82f6') : isActive ? '#475569' : '#94a3b8'};
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1.2;
|
|
||||||
white-space: nowrap;
|
|
||||||
">
|
|
||||||
${statusLabels[status]}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Connecting line (except for the last status)
|
|
||||||
if (index < statuses.length - 1) {
|
|
||||||
const nextIsActive = (index + 1) <= currentIndex;
|
|
||||||
const connectionColor = nextIsActive ? lineColor : '#e2e8f0';
|
|
||||||
|
|
||||||
progressBarHtml += `
|
|
||||||
<td style="padding: 0; vertical-align: top; position: relative; width: 20px;">
|
|
||||||
<div style="
|
|
||||||
height: 2px;
|
|
||||||
background: ${connectionColor};
|
|
||||||
position: absolute;
|
|
||||||
top: 21px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 3;
|
|
||||||
"></div>
|
|
||||||
</td>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
progressBarHtml += `
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return progressBarHtml;
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${subject}</title>
|
|
||||||
</head>
|
|
||||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
||||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Update</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
|
||||||
<h2 style="margin-top: 0; color: #2c3e50;">Status Update</h2>
|
|
||||||
<p>Hello ${user.name},</p>
|
|
||||||
<p>Your reimbursement request "<strong>${reimbursement.title}</strong>" has been updated.</p>
|
|
||||||
|
|
||||||
${data.useImageProgress ?
|
|
||||||
`<div style="text-align: center; margin: 20px 0;">
|
|
||||||
<img src="${getStatusImageUrl(data.newStatus, 'https://ieeeatucsd.org')}" alt="Request Progress" style="max-width: 100%; height: auto; border-radius: 8px;" />
|
|
||||||
</div>` :
|
|
||||||
generateStatusProgressBar(data.newStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${statusColor}; margin: 20px 0;">
|
|
||||||
<div style="margin-bottom: 15px;">
|
|
||||||
<span style="font-weight: bold; color: #666;">Status:</span>
|
|
||||||
<span style="background: ${statusColor}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500; margin-left: 10px;">${statusText}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${data.previousStatus && data.previousStatus !== data.newStatus ? `
|
|
||||||
<div style="color: #666; font-size: 14px;">
|
|
||||||
Changed from: <span style="text-decoration: line-through;">${getStatusText(data.previousStatus)}</span> → <strong>${statusText}</strong>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
${changedByName !== 'System' ? `
|
|
||||||
<div style="color: #666; font-size: 14px; margin-top: 10px;">
|
|
||||||
Updated by: ${changedByName}
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
${data.newStatus === 'rejected' && data.additionalContext?.rejectionReason ? `
|
|
||||||
<div style="background: #f8d7da; padding: 15px; border-radius: 6px; border: 1px solid #f5c6cb; margin-top: 15px;">
|
|
||||||
<div style="font-weight: bold; color: #721c24; margin-bottom: 8px;">Rejection Reason:</div>
|
|
||||||
<div style="color: #721c24; font-style: italic;">${data.additionalContext.rejectionReason}</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin: 25px 0;">
|
|
||||||
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Amount:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-weight: bold;">Payment Method:</td>
|
|
||||||
<td style="padding: 8px 0;">${reimbursement.payment_method}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;">
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Next Steps:</strong></p>
|
|
||||||
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
|
||||||
${getNextStepsText(data.newStatus)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
|
||||||
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
|
||||||
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log('Attempting to send email via Resend...');
|
|
||||||
const result = await resend.emails.send({
|
|
||||||
from: fromEmail,
|
|
||||||
to: [user.email],
|
|
||||||
replyTo: replyToEmail,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Resend response:', result);
|
|
||||||
console.log('Status change email sent successfully!');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send status change email:', error);
|
|
||||||
console.error('Error details:', {
|
|
||||||
name: error instanceof Error ? error.name : 'Unknown',
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendCommentEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
console.log('Starting comment email process...');
|
|
||||||
console.log('Comment data received:', {
|
|
||||||
reimbursementId: data.reimbursementId,
|
|
||||||
commentByUserId: data.commentByUserId,
|
|
||||||
isPrivate: data.isPrivate,
|
|
||||||
commentLength: data.comment?.length || 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// Don't send emails for private comments
|
|
||||||
if (data.isPrivate) {
|
|
||||||
console.log('Comment is private, skipping email notification');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get reimbursement details
|
|
||||||
console.log('Fetching reimbursement details for:', data.reimbursementId);
|
|
||||||
const reimbursement = await pb.collection('reimbursement').getOne(data.reimbursementId);
|
|
||||||
console.log('Reimbursement fetched:', {
|
|
||||||
id: reimbursement.id,
|
|
||||||
title: reimbursement.title,
|
|
||||||
submitted_by: reimbursement.submitted_by
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get submitter user details
|
|
||||||
console.log('Fetching submitter user details for:', reimbursement.submitted_by);
|
|
||||||
const user = await pb.collection('users').getOne(reimbursement.submitted_by);
|
|
||||||
if (!user || !user.email) {
|
|
||||||
console.error('User not found or no email:', reimbursement.submitted_by);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
console.log('Submitter user fetched:', {
|
|
||||||
id: user.id,
|
|
||||||
name: user.name,
|
|
||||||
email: user.email
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get commenter user name
|
|
||||||
console.log('Fetching commenter user details for:', data.commentByUserId);
|
|
||||||
let commentByName = 'Unknown User';
|
|
||||||
try {
|
|
||||||
const commentByUser = await pb.collection('users').getOne(data.commentByUserId);
|
|
||||||
commentByName = commentByUser?.name || 'Unknown User';
|
|
||||||
console.log('Commenter user fetched:', {
|
|
||||||
id: commentByUser?.id,
|
|
||||||
name: commentByName
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Could not get commenter user name:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const subject = `New Comment on Reimbursement: ${reimbursement.title}`;
|
|
||||||
|
|
||||||
console.log('Comment email details:', {
|
|
||||||
to: user.email,
|
|
||||||
subject,
|
|
||||||
commentBy: commentByName,
|
|
||||||
commentPreview: data.comment.substring(0, 50) + (data.comment.length > 50 ? '...' : '')
|
|
||||||
});
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${subject}</title>
|
|
||||||
</head>
|
|
||||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
||||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Comment</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
|
||||||
<h2 style="margin-top: 0; color: #2c3e50;">New Comment Added</h2>
|
|
||||||
<p>Hello ${user.name},</p>
|
|
||||||
<p>A new comment has been added to your reimbursement request "<strong>${reimbursement.title}</strong>".</p>
|
|
||||||
|
|
||||||
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #3498db; margin: 20px 0;">
|
|
||||||
<div style="margin-bottom: 15px;">
|
|
||||||
<span style="font-weight: bold; color: #2980b9;">Comment by:</span> ${commentByName}
|
|
||||||
</div>
|
|
||||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px;">
|
|
||||||
<p style="margin: 0; font-style: italic;">${data.comment}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin: 25px 0;">
|
|
||||||
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Status:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">
|
|
||||||
<span style="background: ${getStatusColor(reimbursement.status)}; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
|
||||||
${getStatusText(reimbursement.status)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-weight: bold;">Date of Purchase:</td>
|
|
||||||
<td style="padding: 8px 0;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
|
||||||
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
|
||||||
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log('Attempting to send comment email via Resend...');
|
|
||||||
const result = await resend.emails.send({
|
|
||||||
from: fromEmail,
|
|
||||||
to: [user.email],
|
|
||||||
replyTo: replyToEmail,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Resend comment email response:', result);
|
|
||||||
console.log('Comment email sent successfully!');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send comment email:', error);
|
|
||||||
console.error('Comment email error details:', {
|
|
||||||
name: error instanceof Error ? error.name : 'Unknown',
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendSubmissionEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Get reimbursement details
|
|
||||||
const reimbursement = await pb.collection('reimbursement').getOne(data.reimbursementId);
|
|
||||||
|
|
||||||
// Get submitter user details
|
|
||||||
const user = await pb.collection('users').getOne(reimbursement.submitted_by);
|
|
||||||
if (!user || !user.email) {
|
|
||||||
console.error('User not found or no email:', reimbursement.submitted_by);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send confirmation email to submitter
|
|
||||||
const submitterSubject = `Reimbursement Submitted: ${reimbursement.title}`;
|
|
||||||
|
|
||||||
const submitterHtml = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${submitterSubject}</title>
|
|
||||||
</head>
|
|
||||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
||||||
<div style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 24px;">Reimbursement Submitted Successfully</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
|
||||||
<h2 style="margin-top: 0; color: #2c3e50;">Submission Confirmed</h2>
|
|
||||||
<p>Hello ${user.name},</p>
|
|
||||||
<p>Your reimbursement request has been successfully submitted and is now under review.</p>
|
|
||||||
|
|
||||||
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
|
||||||
<h3 style="margin-top: 0; color: #155724;">Reimbursement Details</h3>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Title:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.title}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Payment Method:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.payment_method}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-weight: bold;">Status:</td>
|
|
||||||
<td style="padding: 8px 0;">
|
|
||||||
<span style="background: #ffc107; color: #212529; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
|
||||||
Submitted
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
|
||||||
<h4 style="margin: 0 0 10px 0; color: #155724;">What happens next?</h4>
|
|
||||||
<ul style="margin: 0; padding-left: 20px; color: #155724;">
|
|
||||||
<li>Your receipts will be reviewed by our team</li>
|
|
||||||
<li>You'll receive email updates as the status changes</li>
|
|
||||||
<li>Once approved, payment will be processed</li>
|
|
||||||
<li>Typical processing time is 1-2 weeks</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
|
||||||
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
|
||||||
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Send notification email to treasurer
|
|
||||||
const treasurerSubject = `New Reimbursement Request: ${reimbursement.title} - $${reimbursement.total_amount.toFixed(2)}`;
|
|
||||||
|
|
||||||
const treasurerHtml = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${treasurerSubject}</title>
|
|
||||||
</head>
|
|
||||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
||||||
<div style="background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 24px;">New Reimbursement Request</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
|
||||||
<h2 style="margin-top: 0; color: #2c3e50;">Action Required</h2>
|
|
||||||
<p>Hello Treasurer,</p>
|
|
||||||
<p>A new reimbursement request has been submitted and is awaiting review.</p>
|
|
||||||
|
|
||||||
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #007bff; margin: 20px 0;">
|
|
||||||
<h3 style="margin-top: 0; color: #004085;">Reimbursement Details</h3>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Submitted by:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${user.name} (${user.email})</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Title:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.title}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; color: #28a745;">$${reimbursement.total_amount.toFixed(2)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Payment Method:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.payment_method}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Submitted:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.created).toLocaleString()}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-weight: bold;">Status:</td>
|
|
||||||
<td style="padding: 8px 0;">
|
|
||||||
<span style="background: #ffc107; color: #212529; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
|
||||||
Submitted - Awaiting Review
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
${reimbursement.additional_info ? `
|
|
||||||
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #eee;">
|
|
||||||
<h4 style="margin: 0 0 10px 0; color: #495057;">Additional Information:</h4>
|
|
||||||
<div style="background: #f8f9fa; padding: 12px; border-radius: 6px; font-style: italic;">
|
|
||||||
${reimbursement.additional_info}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #e7f3ff; padding: 15px; border-radius: 8px; border-left: 4px solid #007bff; margin: 20px 0;">
|
|
||||||
<h4 style="margin: 0 0 10px 0; color: #004085;">Next Steps:</h4>
|
|
||||||
<ul style="margin: 0; padding-left: 20px; color: #004085;">
|
|
||||||
<li>Review the submitted receipts and documentation</li>
|
|
||||||
<li>Log into the reimbursement portal to approve or request changes</li>
|
|
||||||
<li>The submitter will be notified of any status updates</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
|
||||||
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
|
||||||
<p>If you have any questions, please contact the submitter directly at <a href="mailto:${user.email}" style="color: #667eea;">${user.email}</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Send both emails
|
|
||||||
const submitterResult = await resend.emails.send({
|
|
||||||
from: fromEmail,
|
|
||||||
to: [user.email],
|
|
||||||
replyTo: replyToEmail,
|
|
||||||
subject: submitterSubject,
|
|
||||||
html: submitterHtml,
|
|
||||||
});
|
|
||||||
|
|
||||||
const treasurerResult = await resend.emails.send({
|
|
||||||
from: fromEmail,
|
|
||||||
to: ['treasurer@ieeeatucsd.org'],
|
|
||||||
replyTo: user.email, // Set reply-to as the submitter for treasurer's convenience
|
|
||||||
subject: treasurerSubject,
|
|
||||||
html: treasurerHtml,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Submission confirmation email sent successfully:', submitterResult);
|
|
||||||
console.log('Treasurer notification email sent successfully:', treasurerResult);
|
|
||||||
|
|
||||||
// Return true if at least one email was sent successfully
|
|
||||||
return !!(submitterResult && treasurerResult);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send submission emails:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendTestEmail(resend: any, fromEmail: string, replyToEmail: string, email: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
console.log('Starting test email process...');
|
|
||||||
console.log('Test email configuration:', {
|
|
||||||
fromEmail,
|
|
||||||
replyToEmail,
|
|
||||||
toEmail: email,
|
|
||||||
hasResend: !!resend
|
|
||||||
});
|
|
||||||
|
|
||||||
const subject = 'Test Email from IEEE UCSD Reimbursement System';
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${subject}</title>
|
|
||||||
</head>
|
|
||||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
||||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 24px;">Test Email</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
|
||||||
<h2 style="margin-top: 0; color: #2c3e50;">Email System Test</h2>
|
|
||||||
<p>This is a test email from the IEEE UCSD Reimbursement System.</p>
|
|
||||||
<p>If you receive this email, the notification system is working correctly!</p>
|
|
||||||
|
|
||||||
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
|
||||||
<p style="margin: 0; color: #155724;">Email delivery successful</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
|
||||||
<p>This is a test notification from IEEE UCSD Reimbursement System.</p>
|
|
||||||
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log('Sending test email via Resend...');
|
|
||||||
const result = await resend.emails.send({
|
|
||||||
from: fromEmail,
|
|
||||||
to: [email],
|
|
||||||
replyTo: replyToEmail,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Resend test email response:', result);
|
|
||||||
console.log('Test email sent successfully!');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send test email:', error);
|
|
||||||
console.error('Test email error details:', {
|
|
||||||
name: error instanceof Error ? error.name : 'Unknown',
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,98 +0,0 @@
|
||||||
import type { APIRoute } from 'astro';
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
|
||||||
try {
|
|
||||||
console.log('📨 Email notification API called (legacy endpoint)');
|
|
||||||
|
|
||||||
const requestData = await request.json();
|
|
||||||
const { type, reimbursementId, eventRequestId } = requestData;
|
|
||||||
|
|
||||||
console.log('📋 Request data:', {
|
|
||||||
type,
|
|
||||||
reimbursementId,
|
|
||||||
eventRequestId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!type) {
|
|
||||||
console.error('❌ Missing required parameter: type');
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Missing required parameter: type' }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which endpoint to redirect to based on email type
|
|
||||||
const reimbursementTypes = ['status_change', 'comment', 'submission', 'test'];
|
|
||||||
const eventRequestTypes = ['event_request_submission', 'event_request_status_change', 'pr_completed', 'design_pr_notification'];
|
|
||||||
const officerTypes = ['officer_role_change'];
|
|
||||||
|
|
||||||
let targetEndpoint = '';
|
|
||||||
|
|
||||||
if (reimbursementTypes.includes(type)) {
|
|
||||||
if (!reimbursementId && type !== 'test') {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Missing reimbursementId for reimbursement notification' }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
targetEndpoint = '/api/email/send-reimbursement-email';
|
|
||||||
} else if (eventRequestTypes.includes(type)) {
|
|
||||||
if (!eventRequestId) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Missing eventRequestId for event request notification' }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
targetEndpoint = '/api/email/send-event-request-email';
|
|
||||||
} else if (officerTypes.includes(type)) {
|
|
||||||
const { officerId } = requestData;
|
|
||||||
if (!officerId) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Missing officerId for officer notification' }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
targetEndpoint = '/api/email/send-officer-notification';
|
|
||||||
} else {
|
|
||||||
console.error('❌ Unknown notification type:', type);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: `Unknown notification type: ${type}` }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🔄 Redirecting ${type} to ${targetEndpoint}`);
|
|
||||||
|
|
||||||
// Forward the request to the appropriate endpoint
|
|
||||||
const baseUrl = new URL(request.url).origin;
|
|
||||||
const response = await fetch(`${baseUrl}${targetEndpoint}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestData),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
console.log(`📊 Forwarded request result: ${result.success ? 'SUCCESS' : 'FAILED'}`);
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify(result),
|
|
||||||
{
|
|
||||||
status: response.status,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error in email notification API:', error);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Internal server error',
|
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
}),
|
|
||||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,258 +0,0 @@
|
||||||
import type { APIRoute } from 'astro';
|
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ url }) => {
|
|
||||||
try {
|
|
||||||
const searchParams = new URL(url).searchParams;
|
|
||||||
const status = searchParams.get('status') || 'submitted';
|
|
||||||
const width = parseInt(searchParams.get('width') || '500');
|
|
||||||
const height = parseInt(searchParams.get('height') || '150');
|
|
||||||
|
|
||||||
console.log('🎨 Generating SVG status image for:', { status, width, height });
|
|
||||||
|
|
||||||
// Generate SVG status progress bar
|
|
||||||
function generateSVGProgressBar(currentStatus: string): string {
|
|
||||||
const statusOrder = ['submitted', 'under_review', 'approved', 'in_progress', 'paid'];
|
|
||||||
const rejectedStatus = ['submitted', 'under_review', 'rejected'];
|
|
||||||
|
|
||||||
const isRejected = currentStatus === 'rejected';
|
|
||||||
const statuses = isRejected ? rejectedStatus : statusOrder;
|
|
||||||
|
|
||||||
const statusIcons: Record<string, string> = {
|
|
||||||
submitted: '→',
|
|
||||||
under_review: '?',
|
|
||||||
approved: '✓',
|
|
||||||
rejected: '✗',
|
|
||||||
in_progress: '○',
|
|
||||||
paid: '$'
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
|
||||||
submitted: 'Submitted',
|
|
||||||
under_review: 'Under Review',
|
|
||||||
approved: 'Approved',
|
|
||||||
rejected: 'Rejected',
|
|
||||||
in_progress: 'In Progress',
|
|
||||||
paid: 'Paid'
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentIndex = statuses.indexOf(currentStatus);
|
|
||||||
const circleRadius = 44; // Double for 2x resolution
|
|
||||||
const lineY = height;
|
|
||||||
const totalWidth = width * 1.6; // Double for 2x resolution (80% of doubled width)
|
|
||||||
const startX = width * 0.2; // Double for 2x resolution (10% of doubled width)
|
|
||||||
const stepWidth = totalWidth / (statuses.length - 1);
|
|
||||||
|
|
||||||
let svgElements = '';
|
|
||||||
|
|
||||||
// Generate background line (behind circles) - doubled for 2x resolution
|
|
||||||
svgElements += `<line x1="${startX}" y1="${lineY + 2}" x2="${startX + totalWidth}" y2="${lineY + 2}" stroke="#e2e8f0" stroke-width="8" opacity="0.6"/>`;
|
|
||||||
|
|
||||||
// Generate progress line up to current status
|
|
||||||
if (currentIndex >= 0) {
|
|
||||||
const progressEndX = startX + (currentIndex * stepWidth);
|
|
||||||
let progressColor = '#3b82f6'; // Default blue
|
|
||||||
|
|
||||||
// Set progress color based on current status
|
|
||||||
if (currentStatus === 'rejected') {
|
|
||||||
progressColor = '#ef4444';
|
|
||||||
} else if (currentStatus === 'paid') {
|
|
||||||
progressColor = '#10b981';
|
|
||||||
} else if (currentStatus === 'in_progress') {
|
|
||||||
progressColor = '#f59e0b';
|
|
||||||
}
|
|
||||||
|
|
||||||
svgElements += `<line x1="${startX}" y1="${lineY + 2}" x2="${progressEndX}" y2="${lineY + 2}" stroke="${progressColor}" stroke-width="6" opacity="0.9"/>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate status circles and labels
|
|
||||||
statuses.forEach((statusName, index) => {
|
|
||||||
const x = startX + (index * stepWidth);
|
|
||||||
const y = lineY;
|
|
||||||
const isActive = index <= currentIndex;
|
|
||||||
const isCurrent = statusName === currentStatus;
|
|
||||||
|
|
||||||
let backgroundColor, textColor;
|
|
||||||
if (isCurrent) {
|
|
||||||
if (statusName === 'rejected') {
|
|
||||||
backgroundColor = '#ef4444';
|
|
||||||
textColor = 'white';
|
|
||||||
} else if (statusName === 'paid') {
|
|
||||||
backgroundColor = '#10b981';
|
|
||||||
textColor = 'white';
|
|
||||||
} else if (statusName === 'in_progress') {
|
|
||||||
backgroundColor = '#f59e0b';
|
|
||||||
textColor = 'white';
|
|
||||||
} else {
|
|
||||||
backgroundColor = '#3b82f6';
|
|
||||||
textColor = 'white';
|
|
||||||
}
|
|
||||||
} else if (isActive) {
|
|
||||||
backgroundColor = '#e2e8f0';
|
|
||||||
textColor = '#475569';
|
|
||||||
} else {
|
|
||||||
backgroundColor = '#f8fafc';
|
|
||||||
textColor = '#94a3b8';
|
|
||||||
}
|
|
||||||
|
|
||||||
const labelColor = isCurrent ?
|
|
||||||
(statusName === 'rejected' ? '#ef4444' :
|
|
||||||
statusName === 'paid' ? '#10b981' :
|
|
||||||
statusName === 'in_progress' ? '#f59e0b' : '#3b82f6') :
|
|
||||||
isActive ? '#475569' : '#94a3b8';
|
|
||||||
|
|
||||||
// Circle with shadow effect
|
|
||||||
svgElements += `<circle cx="${x}" cy="${y}" r="${circleRadius}" fill="${backgroundColor}" stroke="white" stroke-width="6" filter="url(#shadow)"/>`;
|
|
||||||
|
|
||||||
// Icon text - properly centered with dominant-baseline (doubled font size)
|
|
||||||
svgElements += `<text x="${x}" y="${y}" text-anchor="middle" dominant-baseline="central" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="36" font-weight="bold" fill="${textColor}">${statusIcons[statusName]}</text>`;
|
|
||||||
|
|
||||||
// Label text (doubled font size)
|
|
||||||
svgElements += `<text x="${x}" y="${y + circleRadius + 36}" text-anchor="middle" dominant-baseline="central" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="22" font-weight="600" fill="${labelColor}">${statusLabels[statusName]}</text>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return `
|
|
||||||
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<defs>
|
|
||||||
<style>
|
|
||||||
text {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<!-- Drop shadow filter -->
|
|
||||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feDropShadow dx="0" dy="4" stdDeviation="6" flood-opacity="0.3"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Title (doubled font size) -->
|
|
||||||
<text x="${width}" y="50" text-anchor="middle" dominant-baseline="central" font-size="32" font-weight="700" fill="#1e293b">Request Progress</text>
|
|
||||||
|
|
||||||
${svgElements}
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const svg = generateSVGProgressBar(status);
|
|
||||||
|
|
||||||
console.log('✅ SVG generated, converting to PNG with Puppeteer...');
|
|
||||||
|
|
||||||
// Convert SVG to PNG using Puppeteer
|
|
||||||
const browser = await puppeteer.launch({
|
|
||||||
headless: true,
|
|
||||||
args: [
|
|
||||||
'--no-sandbox',
|
|
||||||
'--disable-setuid-sandbox',
|
|
||||||
'--disable-dev-shm-usage', // Overcome limited resource problems
|
|
||||||
'--disable-accelerated-2d-canvas',
|
|
||||||
'--no-first-run',
|
|
||||||
'--no-zygote',
|
|
||||||
'--single-process', // For limited memory environments
|
|
||||||
'--disable-gpu',
|
|
||||||
'--disable-web-security',
|
|
||||||
'--disable-features=VizDisplayCompositor',
|
|
||||||
'--disable-extensions',
|
|
||||||
'--disable-default-apps',
|
|
||||||
'--disable-sync',
|
|
||||||
'--no-default-browser-check',
|
|
||||||
'--force-device-scale-factor=2' // Higher DPI for better quality
|
|
||||||
],
|
|
||||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, // Allow custom Chromium path
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('🚀 Puppeteer browser launched successfully');
|
|
||||||
|
|
||||||
const page = await browser.newPage();
|
|
||||||
|
|
||||||
// Set high-resolution viewport for better quality
|
|
||||||
await page.setViewport({
|
|
||||||
width: width * 2, // Double resolution for crisp images
|
|
||||||
height: height * 2,
|
|
||||||
deviceScaleFactor: 2
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('📄 Setting SVG content...');
|
|
||||||
|
|
||||||
// Create HTML wrapper for the SVG
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
width: ${width * 2}px;
|
|
||||||
height: ${height * 2}px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
svg {
|
|
||||||
width: ${width * 2}px;
|
|
||||||
height: ${height * 2}px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
${svg.replace(`width="${width}" height="${height}"`, `width="${width * 2}" height="${height * 2}"`)}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Set HTML content
|
|
||||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
|
||||||
|
|
||||||
console.log('📸 Taking screenshot...');
|
|
||||||
|
|
||||||
// Take high-quality screenshot with transparent background
|
|
||||||
const screenshot = await page.screenshot({
|
|
||||||
type: 'png',
|
|
||||||
fullPage: false,
|
|
||||||
omitBackground: true, // Transparent background
|
|
||||||
clip: {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: width * 2,
|
|
||||||
height: height * 2
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
console.log('🔒 Browser closed successfully');
|
|
||||||
|
|
||||||
console.log('✅ PNG image generated successfully from SVG');
|
|
||||||
|
|
||||||
return new Response(screenshot, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'image/png',
|
|
||||||
'Cache-Control': 'public, max-age=3600',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error generating SVG status image:', error);
|
|
||||||
console.error('Error details:', {
|
|
||||||
name: error instanceof Error ? error.name : 'Unknown',
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return more detailed error information
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Failed to generate status image',
|
|
||||||
details: errorMessage,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -11,7 +11,7 @@ import { OfficerTypes } from "../schemas/pocketbase/schema";
|
||||||
import { initAuthSync } from "../scripts/database/initAuthSync";
|
import { initAuthSync } from "../scripts/database/initAuthSync";
|
||||||
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 "../styles/global.css";
|
||||||
const title = "Dashboard";
|
const title = "Dashboard";
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
|
@ -87,11 +87,11 @@ const components = Object.fromEntries(
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div
|
<div
|
||||||
class="h-6 w-32 bg-base-300 animate-pulse rounded mb-2"
|
class="h-6 w-32 bg-base-300 animate-pulse rounded-sm mb-2"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="h-5 w-20 bg-base-300 animate-pulse rounded"
|
class="h-5 w-20 bg-base-300 animate-pulse rounded-sm"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -128,7 +128,7 @@ const components = Object.fromEntries(
|
||||||
>
|
>
|
||||||
<div class="avatar flex items-center justify-center">
|
<div class="avatar flex items-center justify-center">
|
||||||
<div
|
<div
|
||||||
class="w-12 h-12 rounded-xl bg-[#06659d] text-white ring ring-base-200 ring-offset-base-100 ring-offset-2 inline-flex items-center justify-center"
|
class="w-12 h-12 rounded-xl bg-[#06659d] text-white ring-3 ring-base-200 ring-offset-base-100 ring-offset-2 inline-flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="text-xl font-semibold select-none inline-flex items-center justify-center w-full h-full"
|
class="text-xl font-semibold select-none inline-flex items-center justify-center w-full h-full"
|
||||||
|
@ -163,13 +163,13 @@ const components = Object.fromEntries(
|
||||||
[1, 2, 3].map((group) => (
|
[1, 2, 3].map((group) => (
|
||||||
<>
|
<>
|
||||||
<li class="menu-title font-medium opacity-70">
|
<li class="menu-title font-medium opacity-70">
|
||||||
<div class="h-4 w-24 bg-base-300 animate-pulse rounded" />
|
<div class="h-4 w-24 bg-base-300 animate-pulse rounded-sm" />
|
||||||
</li>
|
</li>
|
||||||
{[1, 2, 3].map((item) => (
|
{[1, 2, 3].map((item) => (
|
||||||
<li>
|
<li>
|
||||||
<div class="flex items-center gap-4 py-2">
|
<div class="flex items-center gap-4 py-2">
|
||||||
<div class="h-5 w-5 bg-base-300 animate-pulse rounded" />
|
<div class="h-5 w-5 bg-base-300 animate-pulse rounded-sm" />
|
||||||
<div class="h-4 w-32 bg-base-300 animate-pulse rounded" />
|
<div class="h-4 w-32 bg-base-300 animate-pulse rounded-sm" />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
@ -221,7 +221,7 @@ const components = Object.fromEntries(
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class={`dashboard-nav-btn gap-4 transition-all duration-200 outline-none focus:outline-none hover:bg-opacity-5 ${section.class || ""}`}
|
class={`dashboard-nav-btn gap-4 transition-all duration-200 outline-hidden focus:outline-hidden hover:bg-opacity-5 ${section.class || ""}`}
|
||||||
data-section={
|
data-section={
|
||||||
sectionKey
|
sectionKey
|
||||||
}
|
}
|
||||||
|
@ -246,7 +246,7 @@ const components = Object.fromEntries(
|
||||||
{/* Add Logout Button to the bottom of the menu */}
|
{/* Add Logout Button to the bottom of the menu */}
|
||||||
<li class="mt-auto">
|
<li class="mt-auto">
|
||||||
<button
|
<button
|
||||||
class="dashboard-nav-btn gap-4 transition-all duration-200 outline-none focus:outline-none hover:bg-opacity-5 text-error"
|
class="dashboard-nav-btn gap-4 transition-all duration-200 outline-hidden focus:outline-hidden hover:bg-opacity-5 text-error"
|
||||||
data-section="logout"
|
data-section="logout"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
---
|
---
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
const title = "Authenticating...";
|
const title = "Authenticating...";
|
||||||
|
import "../styles/global.css";
|
||||||
---
|
---
|
||||||
|
|
||||||
<html lang="en" data-theme="dark">
|
<html lang="en" data-theme="dark">
|
||||||
|
|
|
@ -1,156 +0,0 @@
|
||||||
---
|
|
||||||
// Status Images Display Page
|
|
||||||
const statuses = [
|
|
||||||
'submitted',
|
|
||||||
'under_review',
|
|
||||||
'approved',
|
|
||||||
'in_progress',
|
|
||||||
'paid',
|
|
||||||
'rejected'
|
|
||||||
];
|
|
||||||
|
|
||||||
const imageWidth = 500;
|
|
||||||
const imageHeight = 150;
|
|
||||||
---
|
|
||||||
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<meta name="generator" content={Astro.generator} />
|
|
||||||
<title>Reimbursement Status Images</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: #f8fafc;
|
|
||||||
margin: 0;
|
|
||||||
padding: 40px 20px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 40px;
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
color: #1e293b;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
color: #475569;
|
|
||||||
margin: 40px 0 20px 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
border-bottom: 2px solid #e2e8f0;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
.status-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
|
||||||
gap: 30px;
|
|
||||||
margin-bottom: 50px;
|
|
||||||
}
|
|
||||||
.status-item {
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
.status-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #475569;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
.status-image {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
.image-url {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.demo-section {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 30px;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin: 30px 0;
|
|
||||||
border: 2px solid #e2e8f0;
|
|
||||||
}
|
|
||||||
.email-demo {
|
|
||||||
background: white;
|
|
||||||
padding: 30px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Reimbursement Status Progress Images</h1>
|
|
||||||
|
|
||||||
<div class="demo-section">
|
|
||||||
<h2>🖼️ PNG Status Progress Images</h2>
|
|
||||||
<p>High-quality PNG images generated from SVG using Puppeteer with transparent backgrounds:</p>
|
|
||||||
|
|
||||||
<div class="status-grid">
|
|
||||||
{statuses.map((status) => (
|
|
||||||
<div class="status-item">
|
|
||||||
<h3 class="status-title">
|
|
||||||
{status.replace('_', ' ')}
|
|
||||||
</h3>
|
|
||||||
<img
|
|
||||||
src={`/api/generate-status-image?status=${status}&width=${imageWidth}&height=${imageHeight}`}
|
|
||||||
alt={`Status progress for ${status}`}
|
|
||||||
class="status-image"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div class="image-url">
|
|
||||||
🖼️ PNG API: /api/generate-status-image?status={status}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="demo-section">
|
|
||||||
<h2>📨 Email Integration Demo</h2>
|
|
||||||
<p>Here's how the PNG images look when embedded in an email-like environment:</p>
|
|
||||||
|
|
||||||
<div class="email-demo" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
|
|
||||||
<h3 style="color: white; margin: 0 0 20px 0;">IEEE UCSD Reimbursement Update</h3>
|
|
||||||
<p style="color: #f1f5f9; margin-bottom: 20px;">Your reimbursement request has been updated:</p>
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={`/api/generate-status-image?status=approved&width=500&height=150`}
|
|
||||||
alt="Status progress embedded in email"
|
|
||||||
style="width: 100%; max-width: 500px; height: auto; border-radius: 8px;"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p style="color: #f1f5f9; margin-top: 20px; font-size: 14px;">
|
|
||||||
✨ PNG images with transparent backgrounds work perfectly in all email clients
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -112,14 +112,13 @@ export interface EventRequest extends BaseRecord {
|
||||||
other_flyer_type?: string;
|
other_flyer_type?: string;
|
||||||
flyer_advertising_start_date?: string;
|
flyer_advertising_start_date?: string;
|
||||||
flyer_additional_requests?: string;
|
flyer_additional_requests?: string;
|
||||||
flyers_completed?: boolean; // Track if flyers have been completed by PR team
|
|
||||||
photography_needed: boolean;
|
photography_needed: boolean;
|
||||||
required_logos?: string[]; // IEEE, AS, HKN, TESC, PIB, TNT, SWE, OTHER
|
required_logos?: string[]; // IEEE, AS, HKN, TESC, PIB, TNT, SWE, OTHER
|
||||||
other_logos?: string[]; // Array of logo IDs
|
other_logos?: string[]; // Array of logo IDs
|
||||||
advertising_format?: string;
|
advertising_format?: string;
|
||||||
will_or_have_room_booking?: boolean;
|
will_or_have_room_booking?: boolean;
|
||||||
expected_attendance?: number;
|
expected_attendance?: number;
|
||||||
room_booking_files?: string[]; // CHANGED: Multiple files instead of single file
|
room_booking?: string; // signle 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
|
||||||
|
@ -128,7 +127,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -106,7 +106,6 @@ 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) {
|
||||||
|
@ -170,15 +169,12 @@ export class DataSyncService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get existing items to handle conflicts and deletions
|
// Get existing items to handle conflicts
|
||||||
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) => {
|
||||||
|
@ -210,43 +206,7 @@ export class DataSyncService {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle deletions: find items that exist locally but not on server
|
// Store in IndexedDB
|
||||||
// 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
|
||||||
|
@ -488,7 +448,6 @@ 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);
|
||||||
|
@ -505,7 +464,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, detectDeletions);
|
await this.syncCollection<T>(collection, filter, sort, expand);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error syncing ${collection}, using cached data:`, error);
|
console.error(`Error syncing ${collection}, using cached data:`, error);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,264 +0,0 @@
|
||||||
/**
|
|
||||||
* Client-side helper for sending email notifications via API routes
|
|
||||||
* This runs in the browser and calls the server-side email API
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Authentication } from '../pocketbase/Authentication';
|
|
||||||
|
|
||||||
interface EmailNotificationRequest {
|
|
||||||
type: 'status_change' | 'comment' | 'submission' | 'test' | 'event_request_submission' | 'event_request_status_change' | 'pr_completed' | 'design_pr_notification' | 'officer_role_change';
|
|
||||||
reimbursementId?: string;
|
|
||||||
eventRequestId?: string;
|
|
||||||
officerId?: string;
|
|
||||||
previousStatus?: string;
|
|
||||||
newStatus?: string;
|
|
||||||
changedByUserId?: string;
|
|
||||||
comment?: string;
|
|
||||||
commentByUserId?: string;
|
|
||||||
isPrivate?: boolean;
|
|
||||||
declinedReason?: string;
|
|
||||||
additionalContext?: Record<string, any>;
|
|
||||||
authData?: { token: string; model: any };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EmailNotificationResponse {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
error?: string;
|
|
||||||
details?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EmailClient {
|
|
||||||
private static getAuthData(): { token: string; model: any } | null {
|
|
||||||
try {
|
|
||||||
const auth = Authentication.getInstance();
|
|
||||||
const token = auth.getAuthToken();
|
|
||||||
const model = auth.getCurrentUser();
|
|
||||||
|
|
||||||
if (token && model) {
|
|
||||||
return { token, model };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Could not get auth data:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async sendEmailNotification(request: EmailNotificationRequest): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const authData = this.getAuthData();
|
|
||||||
const requestWithAuth = {
|
|
||||||
...request,
|
|
||||||
authData
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch('/api/email/send-reimbursement-notification', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestWithAuth),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result: EmailNotificationResponse = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('Email notification API error:', result.error || result.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.success;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send email notification:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async sendOfficerNotification(request: EmailNotificationRequest): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const authData = this.getAuthData();
|
|
||||||
const requestWithAuth = {
|
|
||||||
...request,
|
|
||||||
authData
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch('/api/email/send-officer-notification', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestWithAuth),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result: EmailNotificationResponse = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('Officer notification API error:', result.error || result.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.success;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send officer notification:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send status change notification
|
|
||||||
*/
|
|
||||||
static async notifyStatusChange(
|
|
||||||
reimbursementId: string,
|
|
||||||
newStatus: string,
|
|
||||||
previousStatus?: string,
|
|
||||||
changedByUserId?: string,
|
|
||||||
additionalContext?: Record<string, any>
|
|
||||||
): Promise<boolean> {
|
|
||||||
return this.sendEmailNotification({
|
|
||||||
type: 'status_change',
|
|
||||||
reimbursementId,
|
|
||||||
newStatus,
|
|
||||||
previousStatus,
|
|
||||||
changedByUserId,
|
|
||||||
additionalContext
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send comment notification
|
|
||||||
*/
|
|
||||||
static async notifyComment(
|
|
||||||
reimbursementId: string,
|
|
||||||
comment: string,
|
|
||||||
commentByUserId: string,
|
|
||||||
isPrivate: boolean = false
|
|
||||||
): Promise<boolean> {
|
|
||||||
return this.sendEmailNotification({
|
|
||||||
type: 'comment',
|
|
||||||
reimbursementId,
|
|
||||||
comment,
|
|
||||||
commentByUserId,
|
|
||||||
isPrivate
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send submission confirmation
|
|
||||||
*/
|
|
||||||
static async notifySubmission(reimbursementId: string): Promise<boolean> {
|
|
||||||
return this.sendEmailNotification({
|
|
||||||
type: 'submission',
|
|
||||||
reimbursementId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send rejection notification with reason
|
|
||||||
*/
|
|
||||||
static async notifyRejection(
|
|
||||||
reimbursementId: string,
|
|
||||||
rejectionReason: string,
|
|
||||||
previousStatus?: string,
|
|
||||||
changedByUserId?: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
return this.sendEmailNotification({
|
|
||||||
type: 'status_change',
|
|
||||||
reimbursementId,
|
|
||||||
newStatus: 'rejected',
|
|
||||||
previousStatus,
|
|
||||||
changedByUserId,
|
|
||||||
additionalContext: { rejectionReason }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send test email
|
|
||||||
*/
|
|
||||||
static async sendTestEmail(): Promise<boolean> {
|
|
||||||
return this.sendEmailNotification({
|
|
||||||
type: 'test',
|
|
||||||
reimbursementId: 'test' // Required but not used for test emails
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send event request submission notification to coordinators
|
|
||||||
*/
|
|
||||||
static async notifyEventRequestSubmission(eventRequestId: string): Promise<boolean> {
|
|
||||||
return this.sendEmailNotification({
|
|
||||||
type: 'event_request_submission',
|
|
||||||
eventRequestId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send email notification when an event request status is changed
|
|
||||||
*/
|
|
||||||
static async notifyEventRequestStatusChange(
|
|
||||||
eventRequestId: string,
|
|
||||||
previousStatus: string,
|
|
||||||
newStatus: string,
|
|
||||||
changedByUserId?: string,
|
|
||||||
declinedReason?: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
return this.sendEmailNotification({
|
|
||||||
type: 'event_request_status_change',
|
|
||||||
eventRequestId,
|
|
||||||
previousStatus,
|
|
||||||
newStatus,
|
|
||||||
changedByUserId,
|
|
||||||
declinedReason
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send email notification when PR work is completed for an event request
|
|
||||||
*/
|
|
||||||
static async notifyPRCompleted(eventRequestId: string): Promise<boolean> {
|
|
||||||
return this.sendEmailNotification({
|
|
||||||
type: 'pr_completed',
|
|
||||||
eventRequestId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send email notification to design team for PR-related actions
|
|
||||||
*/
|
|
||||||
static async notifyDesignTeam(
|
|
||||||
eventRequestId: string,
|
|
||||||
action: 'submission' | 'pr_update' | 'declined'
|
|
||||||
): Promise<boolean> {
|
|
||||||
return this.sendEmailNotification({
|
|
||||||
type: 'design_pr_notification',
|
|
||||||
eventRequestId,
|
|
||||||
additionalContext: { action }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send officer role change notification
|
|
||||||
*/
|
|
||||||
static async notifyOfficerRoleChange(
|
|
||||||
officerId: string,
|
|
||||||
previousRole?: string,
|
|
||||||
previousType?: string,
|
|
||||||
newRole?: string,
|
|
||||||
newType?: string,
|
|
||||||
changedByUserId?: string,
|
|
||||||
isNewOfficer?: boolean
|
|
||||||
): Promise<boolean> {
|
|
||||||
return this.sendOfficerNotification({
|
|
||||||
type: 'officer_role_change',
|
|
||||||
officerId,
|
|
||||||
additionalContext: {
|
|
||||||
previousRole,
|
|
||||||
previousType,
|
|
||||||
newRole,
|
|
||||||
newType,
|
|
||||||
changedByUserId,
|
|
||||||
isNewOfficer
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,112 +0,0 @@
|
||||||
// Shared email helper functions and utilities
|
|
||||||
|
|
||||||
export function getStatusColor(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'submitted': return '#ffc107';
|
|
||||||
case 'under_review': return '#17a2b8';
|
|
||||||
case 'approved': return '#28a745';
|
|
||||||
case 'rejected': return '#dc3545';
|
|
||||||
case 'in_progress': return '#6f42c1';
|
|
||||||
case 'paid': return '#20c997';
|
|
||||||
case 'declined': return '#dc3545';
|
|
||||||
default: return '#6c757d';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStatusText(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'submitted': return 'Submitted';
|
|
||||||
case 'under_review': return 'Under Review';
|
|
||||||
case 'approved': return 'Approved';
|
|
||||||
case 'rejected': return 'Rejected';
|
|
||||||
case 'in_progress': return 'In Progress';
|
|
||||||
case 'paid': return 'Paid';
|
|
||||||
case 'declined': return 'Declined';
|
|
||||||
default: return status.charAt(0).toUpperCase() + status.slice(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNextStepsText(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'submitted':
|
|
||||||
return 'Your reimbursement is in the queue for review. We\'ll notify you once it\'s being processed.';
|
|
||||||
case 'under_review':
|
|
||||||
return 'Our team is currently reviewing your receipts and documentation. No action needed from you.';
|
|
||||||
case 'approved':
|
|
||||||
return 'Your reimbursement has been approved! Payment processing will begin shortly.';
|
|
||||||
case 'rejected':
|
|
||||||
return 'Your reimbursement has been rejected. Please review the rejection reason above and reach out to our treasurer if you have questions or need to resubmit with corrections.';
|
|
||||||
case 'in_progress':
|
|
||||||
return 'Payment is being processed. You should receive your reimbursement within 1-2 business days.';
|
|
||||||
case 'paid':
|
|
||||||
return 'Your reimbursement has been completed! Please check your account for the payment.';
|
|
||||||
default:
|
|
||||||
return 'Check your dashboard for more details about your reimbursement status.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initializeEmailServices() {
|
|
||||||
// Import Resend and create direct PocketBase connection for server-side use
|
|
||||||
const { Resend } = await import('resend');
|
|
||||||
const PocketBase = await import('pocketbase').then(module => module.default);
|
|
||||||
|
|
||||||
// Initialize services
|
|
||||||
const pb = new PocketBase(import.meta.env.POCKETBASE_URL || 'http://127.0.0.1:8090');
|
|
||||||
const resend = new Resend(import.meta.env.RESEND_API_KEY);
|
|
||||||
|
|
||||||
if (!import.meta.env.RESEND_API_KEY) {
|
|
||||||
throw new Error('RESEND_API_KEY environment variable is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromEmail = import.meta.env.FROM_EMAIL || 'IEEE UCSD <noreply@ieeeucsd.org>';
|
|
||||||
const replyToEmail = import.meta.env.REPLY_TO_EMAIL || 'treasurer@ieeeucsd.org';
|
|
||||||
|
|
||||||
return { pb, resend, fromEmail, replyToEmail };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function authenticatePocketBase(pb: any, authData: any) {
|
|
||||||
if (authData && authData.token && authData.model) {
|
|
||||||
console.log('🔐 Authenticating with PocketBase using provided auth data');
|
|
||||||
pb.authStore.save(authData.token, authData.model);
|
|
||||||
console.log('✅ PocketBase authentication successful');
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ No auth data provided, proceeding without authentication');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDateTime(dateString: string): string {
|
|
||||||
try {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleString('en-US', {
|
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
timeZoneName: 'short'
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
return dateString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatFlyerTypes(flyerTypes: string[]): string {
|
|
||||||
if (!flyerTypes || flyerTypes.length === 0) return 'None specified';
|
|
||||||
|
|
||||||
const typeMap: Record<string, string> = {
|
|
||||||
'digital_with_social': 'Digital with Social Media',
|
|
||||||
'digital_no_social': 'Digital without Social Media',
|
|
||||||
'physical_with_advertising': 'Physical with Advertising',
|
|
||||||
'physical_no_advertising': 'Physical without Advertising',
|
|
||||||
'newsletter': 'Newsletter',
|
|
||||||
'other': 'Other'
|
|
||||||
};
|
|
||||||
|
|
||||||
return flyerTypes.map(type => typeMap[type] || type).join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatLogos(logos: string[]): string {
|
|
||||||
if (!logos || logos.length === 0) return 'None specified';
|
|
||||||
return logos.join(', ');
|
|
||||||
}
|
|
|
@ -1,410 +0,0 @@
|
||||||
import { Resend } from 'resend';
|
|
||||||
import type { User, Reimbursement, Receipt } from '../../schemas/pocketbase';
|
|
||||||
|
|
||||||
// Define email template types
|
|
||||||
export type EmailTemplateType =
|
|
||||||
| 'reimbursement_status_changed'
|
|
||||||
| 'reimbursement_comment_added'
|
|
||||||
| 'reimbursement_submitted'
|
|
||||||
| 'reimbursement_approved'
|
|
||||||
| 'reimbursement_rejected'
|
|
||||||
| 'reimbursement_paid';
|
|
||||||
|
|
||||||
// Email template data interfaces
|
|
||||||
export interface StatusChangeEmailData {
|
|
||||||
user: User;
|
|
||||||
reimbursement: Reimbursement;
|
|
||||||
previousStatus: string;
|
|
||||||
newStatus: string;
|
|
||||||
changedBy?: string;
|
|
||||||
comment?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommentEmailData {
|
|
||||||
user: User;
|
|
||||||
reimbursement: Reimbursement;
|
|
||||||
comment: string;
|
|
||||||
commentBy: string;
|
|
||||||
isPrivate: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReimbursementEmailData {
|
|
||||||
user: User;
|
|
||||||
reimbursement: Reimbursement;
|
|
||||||
receipts?: Receipt[];
|
|
||||||
additionalData?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EmailService {
|
|
||||||
private resend: Resend;
|
|
||||||
private fromEmail: string;
|
|
||||||
private replyToEmail: string;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Initialize Resend with API key from environment
|
|
||||||
// Use import.meta.env as used throughout the Astro project
|
|
||||||
const apiKey = import.meta.env.RESEND_API_KEY;
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error('RESEND_API_KEY environment variable is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.resend = new Resend(apiKey);
|
|
||||||
this.fromEmail = import.meta.env.FROM_EMAIL || 'IEEE UCSD <noreply@transactional.ieeeatucsd.org>';
|
|
||||||
this.replyToEmail = import.meta.env.REPLY_TO_EMAIL || 'ieee@ucsd.edu';
|
|
||||||
}
|
|
||||||
|
|
||||||
private static instance: EmailService | null = null;
|
|
||||||
|
|
||||||
public static getInstance(): EmailService {
|
|
||||||
if (!EmailService.instance) {
|
|
||||||
EmailService.instance = new EmailService();
|
|
||||||
}
|
|
||||||
return EmailService.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send reimbursement status change notification
|
|
||||||
*/
|
|
||||||
async sendStatusChangeEmail(data: StatusChangeEmailData): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const { user, reimbursement, previousStatus, newStatus, changedBy, comment } = data;
|
|
||||||
|
|
||||||
const subject = `Reimbursement Status Updated: ${reimbursement.title}`;
|
|
||||||
const statusColor = this.getStatusColor(newStatus);
|
|
||||||
const statusText = this.getStatusText(newStatus);
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${subject}</title>
|
|
||||||
</head>
|
|
||||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
||||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Update</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
|
||||||
<h2 style="margin-top: 0; color: #2c3e50;">Status Update</h2>
|
|
||||||
<p>Hello ${user.name},</p>
|
|
||||||
<p>Your reimbursement request "<strong>${reimbursement.title}</strong>" has been updated.</p>
|
|
||||||
|
|
||||||
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${statusColor}; margin: 20px 0;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
|
||||||
<span style="font-weight: bold; color: #666;">Status Change:</span>
|
|
||||||
<span style="background: ${statusColor}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500;">${statusText}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${previousStatus !== newStatus ? `
|
|
||||||
<div style="color: #666; font-size: 14px;">
|
|
||||||
Changed from: <span style="text-decoration: line-through;">${this.getStatusText(previousStatus)}</span> → <strong>${statusText}</strong>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
${changedBy ? `
|
|
||||||
<div style="color: #666; font-size: 14px; margin-top: 10px;">
|
|
||||||
Updated by: ${changedBy}
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${comment ? `
|
|
||||||
<div style="background: #e8f4fd; padding: 15px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #3498db;">
|
|
||||||
<h4 style="margin: 0 0 10px 0; color: #2980b9;">Additional Note:</h4>
|
|
||||||
<p style="margin: 0; font-style: italic;">${comment}</p>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
<div style="margin: 25px 0;">
|
|
||||||
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Amount:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-weight: bold;">Payment Method:</td>
|
|
||||||
<td style="padding: 8px 0;">${reimbursement.payment_method}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;">
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Next Steps:</strong></p>
|
|
||||||
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
|
||||||
${this.getNextStepsText(newStatus)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
|
||||||
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
|
||||||
<p>If you have any questions, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await this.resend.emails.send({
|
|
||||||
from: this.fromEmail,
|
|
||||||
to: [user.email],
|
|
||||||
replyTo: this.replyToEmail,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Status change email sent successfully:', result);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send status change email:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send comment notification email
|
|
||||||
*/
|
|
||||||
async sendCommentEmail(data: CommentEmailData): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const { user, reimbursement, comment, commentBy, isPrivate } = data;
|
|
||||||
|
|
||||||
// Don't send email for private comments unless the user is the recipient
|
|
||||||
if (isPrivate) {
|
|
||||||
return true; // Silently skip private comments for now
|
|
||||||
}
|
|
||||||
|
|
||||||
const subject = `New Comment on Reimbursement: ${reimbursement.title}`;
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${subject}</title>
|
|
||||||
</head>
|
|
||||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
||||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Comment</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
|
||||||
<h2 style="margin-top: 0; color: #2c3e50;">New Comment Added</h2>
|
|
||||||
<p>Hello ${user.name},</p>
|
|
||||||
<p>A new comment has been added to your reimbursement request "<strong>${reimbursement.title}</strong>".</p>
|
|
||||||
|
|
||||||
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #3498db; margin: 20px 0;">
|
|
||||||
<div style="margin-bottom: 15px;">
|
|
||||||
<span style="font-weight: bold; color: #2980b9;">Comment by:</span> ${commentBy}
|
|
||||||
</div>
|
|
||||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px;">
|
|
||||||
<p style="margin: 0; font-style: italic;">${comment}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin: 25px 0;">
|
|
||||||
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Status:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">
|
|
||||||
<span style="background: ${this.getStatusColor(reimbursement.status)}; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
|
||||||
${this.getStatusText(reimbursement.status)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-weight: bold;">Date of Purchase:</td>
|
|
||||||
<td style="padding: 8px 0;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
|
||||||
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
|
||||||
<p>If you have any questions, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await this.resend.emails.send({
|
|
||||||
from: this.fromEmail,
|
|
||||||
to: [user.email],
|
|
||||||
replyTo: this.replyToEmail,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Comment email sent successfully:', result);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send comment email:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send reimbursement submission confirmation
|
|
||||||
*/
|
|
||||||
async sendSubmissionConfirmation(data: ReimbursementEmailData): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const { user, reimbursement } = data;
|
|
||||||
|
|
||||||
const subject = `Reimbursement Submitted: ${reimbursement.title}`;
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${subject}</title>
|
|
||||||
</head>
|
|
||||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
||||||
<div style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 24px;">✅ Reimbursement Submitted Successfully</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
|
||||||
<h2 style="margin-top: 0; color: #2c3e50;">Submission Confirmed</h2>
|
|
||||||
<p>Hello ${user.name},</p>
|
|
||||||
<p>Your reimbursement request has been successfully submitted and is now under review.</p>
|
|
||||||
|
|
||||||
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
|
||||||
<h3 style="margin-top: 0; color: #155724;">Reimbursement Details</h3>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Title:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.title}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Payment Method:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.payment_method}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-weight: bold;">Status:</td>
|
|
||||||
<td style="padding: 8px 0;">
|
|
||||||
<span style="background: #ffc107; color: #212529; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
|
||||||
Submitted
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
|
||||||
<h4 style="margin: 0 0 10px 0; color: #155724;">What happens next?</h4>
|
|
||||||
<ul style="margin: 0; padding-left: 20px; color: #155724;">
|
|
||||||
<li>Your receipts will be reviewed by our team</li>
|
|
||||||
<li>You'll receive email updates as the status changes</li>
|
|
||||||
<li>Once approved, payment will be processed</li>
|
|
||||||
<li>Typical processing time is 1-2 weeks</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
|
||||||
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
|
|
||||||
<p>If you have any questions, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await this.resend.emails.send({
|
|
||||||
from: this.fromEmail,
|
|
||||||
to: [user.email],
|
|
||||||
replyTo: this.replyToEmail,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Submission confirmation email sent successfully:', result);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send submission confirmation email:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get status color for styling
|
|
||||||
*/
|
|
||||||
private getStatusColor(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'submitted': return '#ffc107';
|
|
||||||
case 'under_review': return '#17a2b8';
|
|
||||||
case 'approved': return '#28a745';
|
|
||||||
case 'rejected': return '#dc3545';
|
|
||||||
case 'in_progress': return '#6f42c1';
|
|
||||||
case 'paid': return '#20c997';
|
|
||||||
default: return '#6c757d';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get human-readable status text
|
|
||||||
*/
|
|
||||||
private getStatusText(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'submitted': return 'Submitted';
|
|
||||||
case 'under_review': return 'Under Review';
|
|
||||||
case 'approved': return 'Approved';
|
|
||||||
case 'rejected': return 'Rejected';
|
|
||||||
case 'in_progress': return 'In Progress';
|
|
||||||
case 'paid': return 'Paid';
|
|
||||||
default: return status.charAt(0).toUpperCase() + status.slice(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get next steps text based on status
|
|
||||||
*/
|
|
||||||
private getNextStepsText(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'submitted':
|
|
||||||
return 'Your reimbursement is in the queue for review. We\'ll notify you once it\'s being processed.';
|
|
||||||
case 'under_review':
|
|
||||||
return 'Our team is currently reviewing your receipts and documentation. No action needed from you.';
|
|
||||||
case 'approved':
|
|
||||||
return 'Your reimbursement has been approved! Payment processing will begin shortly.';
|
|
||||||
case 'rejected':
|
|
||||||
return 'Your reimbursement has been rejected. Please review the comments and reach out if you have questions.';
|
|
||||||
case 'in_progress':
|
|
||||||
return 'Payment is being processed. You should receive your reimbursement within 1-2 business days.';
|
|
||||||
case 'paid':
|
|
||||||
return 'Your reimbursement has been completed! Please check your account for the payment.';
|
|
||||||
default:
|
|
||||||
return 'Check your dashboard for more details about your reimbursement status.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,429 +0,0 @@
|
||||||
import { getStatusColor, getStatusText, formatDateTime, formatFlyerTypes, formatLogos } from './EmailHelpers';
|
|
||||||
|
|
||||||
export async function sendEventRequestSubmissionEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
console.log('🎪 Starting event request submission email process...');
|
|
||||||
|
|
||||||
// Get event request details
|
|
||||||
const eventRequest = await pb.collection('event_request').getOne(data.eventRequestId);
|
|
||||||
const user = await pb.collection('users').getOne(eventRequest.requested_user);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.error('❌ User not found:', eventRequest.requested_user);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const coordinatorsEmail = 'coordinators@ieeeatucsd.org';
|
|
||||||
const subject = `New Event Request Submitted: ${eventRequest.name}`;
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${subject}</title>
|
|
||||||
</head>
|
|
||||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
||||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 24px;">🎪 New Event Request Submitted</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
|
||||||
<h2 style="margin-top: 0; color: #2c3e50;">Event Request Details</h2>
|
|
||||||
<p>Hello Coordinators,</p>
|
|
||||||
<p>A new event request has been submitted by <strong>${user.name}</strong> and requires your review.</p>
|
|
||||||
|
|
||||||
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
|
||||||
<h3 style="margin-top: 0; color: #155724;">Basic Information</h3>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Event Name:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.name}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Location:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.location}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Start Date & Time:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${formatDateTime(eventRequest.start_date_time)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">End Date & Time:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${formatDateTime(eventRequest.end_date_time)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Expected Attendance:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.expected_attendance || 'Not specified'}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-weight: bold;">Submitted By:</td>
|
|
||||||
<td style="padding: 8px 0;">${user.name} (${user.email})</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #17a2b8; margin: 20px 0;">
|
|
||||||
<h3 style="margin-top: 0; color: #0c5460;">Event Description</h3>
|
|
||||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px;">
|
|
||||||
<p style="margin: 0; white-space: pre-wrap;">${eventRequest.event_description || 'No description provided'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
|
||||||
<h4 style="margin: 0 0 10px 0; color: #155724;">Next Steps</h4>
|
|
||||||
<ul style="margin: 0; padding-left: 20px; color: #155724;">
|
|
||||||
<li>Review the event request details in the dashboard</li>
|
|
||||||
<li>Coordinate with the submitter if clarification is needed</li>
|
|
||||||
<li>Assign tasks to appropriate team members</li>
|
|
||||||
<li>Update the event request status once processed</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
|
||||||
<p>This is an automated notification from IEEE UCSD Event Management System.</p>
|
|
||||||
<p>Event Request ID: ${eventRequest.id}</p>
|
|
||||||
<p>If you have any questions, please contact the submitter at <a href="mailto:${user.email}" style="color: #667eea;">${user.email}</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await resend.emails.send({
|
|
||||||
from: fromEmail,
|
|
||||||
to: [coordinatorsEmail],
|
|
||||||
replyTo: user.email,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ Event request notification email sent successfully!');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to send event request notification email:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendEventRequestStatusChangeEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
console.log('🎯 Starting event request status change email process...');
|
|
||||||
|
|
||||||
// Get event request details
|
|
||||||
const eventRequest = await pb.collection('event_request').getOne(data.eventRequestId);
|
|
||||||
const user = await pb.collection('users').getOne(eventRequest.requested_user);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.error('❌ User not found:', eventRequest.requested_user);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const coordinatorsEmail = 'coordinators@ieeeatucsd.org';
|
|
||||||
const userSubject = `Your Event Request Status Updated: ${eventRequest.name}`;
|
|
||||||
|
|
||||||
const userHtml = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${userSubject}</title>
|
|
||||||
</head>
|
|
||||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
||||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Event Request Update</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
|
||||||
<h2 style="margin-top: 0; color: #2c3e50;">Status Update</h2>
|
|
||||||
<p>Hello ${user.name},</p>
|
|
||||||
<p>Your event request "<strong>${eventRequest.name}</strong>" has been updated.</p>
|
|
||||||
|
|
||||||
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${getStatusColor(data.newStatus)}; margin: 20px 0;">
|
|
||||||
<div style="margin-bottom: 15px;">
|
|
||||||
<span style="font-weight: bold; color: #666;">Status:</span>
|
|
||||||
<span style="background: ${getStatusColor(data.newStatus)}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500; margin-left: 10px;">${getStatusText(data.newStatus)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${data.previousStatus && data.previousStatus !== data.newStatus ? `
|
|
||||||
<div style="color: #666; font-size: 14px;">
|
|
||||||
Changed from: <span style="text-decoration: line-through;">${getStatusText(data.previousStatus)}</span> → <strong>${getStatusText(data.newStatus)}</strong>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
${data.newStatus === 'declined' && data.declinedReason ? `
|
|
||||||
<div style="background: #f8d7da; padding: 15px; border-radius: 8px; border-left: 4px solid #dc3545; margin: 15px 0;">
|
|
||||||
<p style="margin: 0; color: #721c24;"><strong>Decline Reason:</strong></p>
|
|
||||||
<p style="margin: 5px 0 0 0; color: #721c24;">${data.declinedReason}</p>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin: 25px 0;">
|
|
||||||
<h3 style="color: #2c3e50; margin-bottom: 15px;">Your Event Request Details</h3>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Event Name:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.name}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Status:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${getStatusText(data.newStatus)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Location:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.location}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-weight: bold;">Event Date:</td>
|
|
||||||
<td style="padding: 8px 0;">${formatDateTime(eventRequest.start_date_time)}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
|
||||||
<p>This is an automated notification from IEEE UCSD Event Management System.</p>
|
|
||||||
<p>Event Request ID: ${eventRequest.id}</p>
|
|
||||||
<p>If you have any questions, please contact us at <a href="mailto:coordinators@ieeeatucsd.org" style="color: #667eea;">coordinators@ieeeatucsd.org</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Send email to user
|
|
||||||
await resend.emails.send({
|
|
||||||
from: fromEmail,
|
|
||||||
to: [user.email],
|
|
||||||
replyTo: replyToEmail,
|
|
||||||
subject: userSubject,
|
|
||||||
html: userHtml,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send email to coordinators
|
|
||||||
const coordinatorSubject = `Event Request Status Updated: ${eventRequest.name}`;
|
|
||||||
await resend.emails.send({
|
|
||||||
from: fromEmail,
|
|
||||||
to: [coordinatorsEmail],
|
|
||||||
replyTo: user.email,
|
|
||||||
subject: coordinatorSubject,
|
|
||||||
html: userHtml.replace(user.name, 'Coordinators').replace('Your event request', `Event request by ${user.name}`),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ Event request status change emails sent successfully!');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to send event request status change email:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendPRCompletedEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
console.log('🎨 Starting PR completed email process...');
|
|
||||||
|
|
||||||
// Get event request details
|
|
||||||
const eventRequest = await pb.collection('event_request').getOne(data.eventRequestId);
|
|
||||||
const user = await pb.collection('users').getOne(eventRequest.requested_user);
|
|
||||||
|
|
||||||
if (!user || !user.email) {
|
|
||||||
console.error('❌ User not found or no email:', eventRequest.requested_user);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subject = `PR Materials Completed for Your Event: ${eventRequest.name}`;
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${subject}</title>
|
|
||||||
</head>
|
|
||||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
||||||
<div style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 24px;">🎨 PR Materials Completed!</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
|
||||||
<h2 style="margin-top: 0; color: #2c3e50;">Great News!</h2>
|
|
||||||
<p>Hello ${user.name},</p>
|
|
||||||
<p>The PR materials for your event "<strong>${eventRequest.name}</strong>" have been completed by our PR team!</p>
|
|
||||||
|
|
||||||
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
|
||||||
<div style="margin-bottom: 15px;">
|
|
||||||
<span style="background: #28a745; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500;">
|
|
||||||
✅ PR Materials Completed
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 style="margin-top: 0; color: #155724;">Event Details</h3>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Event Name:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.name}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Location:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.location}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Event Date:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${formatDateTime(eventRequest.start_date_time)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-weight: bold;">Flyers Needed:</td>
|
|
||||||
<td style="padding: 8px 0;">${eventRequest.flyers_needed ? 'Yes' : 'No'}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;">
|
|
||||||
<h4 style="margin: 0 0 10px 0; color: #856404;">📞 Next Steps</h4>
|
|
||||||
<p style="margin: 0; color: #856404;">
|
|
||||||
<strong>Important:</strong> Please reach out to the Internal team to coordinate any remaining logistics for your event.
|
|
||||||
</p>
|
|
||||||
<p style="margin: 10px 0 0 0; color: #856404;">
|
|
||||||
Contact: <strong>internal@ieeeatucsd.org</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
|
||||||
<p>This is an automated notification from IEEE UCSD Event Management System.</p>
|
|
||||||
<p>Event Request ID: ${eventRequest.id}</p>
|
|
||||||
<p>If you have any questions about your PR materials, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
await resend.emails.send({
|
|
||||||
from: fromEmail,
|
|
||||||
to: [user.email],
|
|
||||||
replyTo: replyToEmail,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ PR completed email sent successfully!');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to send PR completed email:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendDesignPRNotificationEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
console.log('🎨 Starting design PR notification email process...');
|
|
||||||
|
|
||||||
// Get event request details
|
|
||||||
const eventRequest = await pb.collection('event_request').getOne(data.eventRequestId);
|
|
||||||
const user = await pb.collection('users').getOne(eventRequest.requested_user);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.error('❌ User not found:', eventRequest.requested_user);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const designEmail = 'design@ieeeatucsd.org';
|
|
||||||
let subject = '';
|
|
||||||
let actionMessage = '';
|
|
||||||
|
|
||||||
switch (data.action) {
|
|
||||||
case 'submission':
|
|
||||||
subject = `New Event Request with PR Materials: ${eventRequest.name}`;
|
|
||||||
actionMessage = 'A new event request has been submitted that requires PR materials.';
|
|
||||||
break;
|
|
||||||
case 'pr_update':
|
|
||||||
subject = `PR Materials Updated: ${eventRequest.name}`;
|
|
||||||
actionMessage = 'The PR materials for this event request have been updated.';
|
|
||||||
break;
|
|
||||||
case 'declined':
|
|
||||||
subject = `Event Request Declined - PR Work Cancelled: ${eventRequest.name}`;
|
|
||||||
actionMessage = 'This event request has been declined. Please ignore any pending PR work for this event.';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
subject = `Event Request PR Notification: ${eventRequest.name}`;
|
|
||||||
actionMessage = 'There has been an update to an event request requiring PR materials.';
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${subject}</title>
|
|
||||||
</head>
|
|
||||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
||||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 24px;">🎨 IEEE UCSD Design Team Notification</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
|
||||||
<h2 style="margin-top: 0; color: #2c3e50;">PR Materials ${data.action === 'declined' ? 'Cancelled' : 'Required'}</h2>
|
|
||||||
<p>Hello Design Team,</p>
|
|
||||||
<p>${actionMessage}</p>
|
|
||||||
|
|
||||||
<div style="margin: 25px 0;">
|
|
||||||
<h3 style="color: #2c3e50; margin-bottom: 15px;">Event Request Details</h3>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Event Name:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${eventRequest.name}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Action:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${data.action.charAt(0).toUpperCase() + data.action.slice(1)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Submitted By:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${user.name} (${user.email})</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-weight: bold;">Event Description:</td>
|
|
||||||
<td style="padding: 8px 0;">${eventRequest.event_description}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${data.action !== 'declined' ? `
|
|
||||||
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
|
|
||||||
<p style="margin: 0; color: #155724;"><strong>Next Steps:</strong> Please coordinate with the internal team for PR material creation and timeline.</p>
|
|
||||||
</div>
|
|
||||||
` : `
|
|
||||||
<div style="background: #f8d7da; padding: 15px; border-radius: 8px; border-left: 4px solid #dc3545; margin: 20px 0;">
|
|
||||||
<p style="margin: 0; color: #721c24;"><strong>Note:</strong> This event has been declined. No further PR work is needed.</p>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
|
||||||
<p>This is an automated notification from IEEE UCSD Event Management System.</p>
|
|
||||||
<p>Event Request ID: ${eventRequest.id}</p>
|
|
||||||
<p>If you have any questions, please contact <a href="mailto:internal@ieeeatucsd.org" style="color: #667eea;">internal@ieeeatucsd.org</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
await resend.emails.send({
|
|
||||||
from: fromEmail,
|
|
||||||
to: [designEmail],
|
|
||||||
replyTo: replyToEmail,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ Design PR notification email sent successfully!');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to send design PR notification email:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,296 +0,0 @@
|
||||||
import { Resend } from 'resend';
|
|
||||||
import type { User, Officer } from '../../schemas/pocketbase/schema';
|
|
||||||
import { OfficerTypes } from '../../schemas/pocketbase';
|
|
||||||
|
|
||||||
// Email template data interfaces
|
|
||||||
export interface OfficerRoleChangeEmailData {
|
|
||||||
user: User;
|
|
||||||
officer: Officer;
|
|
||||||
previousRole?: string;
|
|
||||||
previousType?: string;
|
|
||||||
newRole: string;
|
|
||||||
newType: string;
|
|
||||||
changedBy?: string;
|
|
||||||
isNewOfficer?: boolean; // If this is a new officer appointment
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OfficerEmailNotifications {
|
|
||||||
private resend: Resend;
|
|
||||||
private fromEmail: string;
|
|
||||||
private replyToEmail: string;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Initialize Resend with API key from environment
|
|
||||||
const apiKey = import.meta.env.RESEND_API_KEY;
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error('RESEND_API_KEY environment variable is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.resend = new Resend(apiKey);
|
|
||||||
this.fromEmail = import.meta.env.FROM_EMAIL || 'IEEE UCSD <noreply@transactional.ieeeatucsd.org>';
|
|
||||||
this.replyToEmail = import.meta.env.REPLY_TO_EMAIL || 'ieee@ucsd.edu';
|
|
||||||
}
|
|
||||||
|
|
||||||
private static instance: OfficerEmailNotifications | null = null;
|
|
||||||
|
|
||||||
public static getInstance(): OfficerEmailNotifications {
|
|
||||||
if (!OfficerEmailNotifications.instance) {
|
|
||||||
OfficerEmailNotifications.instance = new OfficerEmailNotifications();
|
|
||||||
}
|
|
||||||
return OfficerEmailNotifications.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send officer role change notification email
|
|
||||||
*/
|
|
||||||
async sendRoleChangeNotification(data: OfficerRoleChangeEmailData): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const { user, officer, previousRole, previousType, newRole, newType, changedBy, isNewOfficer } = data;
|
|
||||||
|
|
||||||
const subject = isNewOfficer
|
|
||||||
? `Welcome to IEEE UCSD Leadership - ${newRole}`
|
|
||||||
: `Your IEEE UCSD Officer Role has been Updated`;
|
|
||||||
|
|
||||||
const typeColor = this.getOfficerTypeColor(newType);
|
|
||||||
const typeText = this.getOfficerTypeDisplayName(newType);
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${subject}</title>
|
|
||||||
</head>
|
|
||||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
||||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
|
|
||||||
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Officer Update</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
|
|
||||||
<h2 style="margin-top: 0; color: #2c3e50;">
|
|
||||||
${isNewOfficer ? 'Welcome to the Team!' : 'Role Update'}
|
|
||||||
</h2>
|
|
||||||
<p>Hello ${user.name},</p>
|
|
||||||
|
|
||||||
${isNewOfficer ? `
|
|
||||||
<p>Congratulations! You have been appointed as an officer for IEEE UCSD. We're excited to have you join our leadership team!</p>
|
|
||||||
` : `
|
|
||||||
<p>Your officer role has been updated in the IEEE UCSD system.</p>
|
|
||||||
`}
|
|
||||||
|
|
||||||
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${typeColor}; margin: 20px 0;">
|
|
||||||
<div style="margin-bottom: 15px;">
|
|
||||||
<h3 style="margin: 0 0 10px 0; color: #2c3e50;">Your Current Role</h3>
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
|
||||||
<span style="font-weight: bold; font-size: 18px; color: #2c3e50;">${newRole}</span>
|
|
||||||
<span style="background: ${typeColor}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500;">${typeText}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${!isNewOfficer && (previousRole || previousType) ? `
|
|
||||||
<div style="color: #666; font-size: 14px; padding: 10px 0; border-top: 1px solid #eee;">
|
|
||||||
<strong>Previous:</strong> ${previousRole || 'Unknown Role'} (${this.getOfficerTypeDisplayName(previousType || '')})
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
${changedBy ? `
|
|
||||||
<div style="color: #666; font-size: 14px; margin-top: 10px;">
|
|
||||||
${isNewOfficer ? 'Appointed' : 'Updated'} by: ${changedBy}
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin: 25px 0;">
|
|
||||||
<h3 style="color: #2c3e50; margin-bottom: 15px;">Officer Information</h3>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Name:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${user.name}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Email:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${user.email}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Role:</td>
|
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${newRole}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 0; font-weight: bold;">Officer Type:</td>
|
|
||||||
<td style="padding: 8px 0;">${typeText}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${this.getOfficerTypeDescription(newType)}
|
|
||||||
|
|
||||||
<div style="background: #e8f4fd; padding: 15px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #3498db;">
|
|
||||||
<h4 style="margin: 0 0 10px 0; color: #2980b9;">Next Steps:</h4>
|
|
||||||
<ul style="margin: 0; padding-left: 20px;">
|
|
||||||
<li>Check your access to the officer dashboard</li>
|
|
||||||
<li>Familiarize yourself with your new responsibilities</li>
|
|
||||||
<li>Reach out to other officers if you have questions</li>
|
|
||||||
${isNewOfficer ? '<li>Attend the next officer meeting to get up to speed</li>' : ''}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
|
|
||||||
<p>This is an automated notification from IEEE UCSD Officer Management System.</p>
|
|
||||||
<p>If you have any questions about your role, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await this.resend.emails.send({
|
|
||||||
from: this.fromEmail,
|
|
||||||
to: [user.email],
|
|
||||||
replyTo: this.replyToEmail,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Officer role change email sent successfully:', result);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send officer role change email:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get color for officer type badge
|
|
||||||
*/
|
|
||||||
private getOfficerTypeColor(type: string): string {
|
|
||||||
switch (type) {
|
|
||||||
case OfficerTypes.ADMINISTRATOR:
|
|
||||||
return '#dc3545'; // Red for admin
|
|
||||||
case OfficerTypes.EXECUTIVE:
|
|
||||||
return '#6f42c1'; // Purple for executive
|
|
||||||
case OfficerTypes.GENERAL:
|
|
||||||
return '#007bff'; // Blue for general
|
|
||||||
case OfficerTypes.HONORARY:
|
|
||||||
return '#fd7e14'; // Orange for honorary
|
|
||||||
case OfficerTypes.PAST:
|
|
||||||
return '#6c757d'; // Gray for past
|
|
||||||
default:
|
|
||||||
return '#28a745'; // Green as default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get display name for officer type
|
|
||||||
*/
|
|
||||||
private getOfficerTypeDisplayName(type: string): string {
|
|
||||||
switch (type) {
|
|
||||||
case OfficerTypes.ADMINISTRATOR:
|
|
||||||
return 'Administrator';
|
|
||||||
case OfficerTypes.EXECUTIVE:
|
|
||||||
return 'Executive Officer';
|
|
||||||
case OfficerTypes.GENERAL:
|
|
||||||
return 'General Officer';
|
|
||||||
case OfficerTypes.HONORARY:
|
|
||||||
return 'Honorary Officer';
|
|
||||||
case OfficerTypes.PAST:
|
|
||||||
return 'Past Officer';
|
|
||||||
default:
|
|
||||||
return 'Officer';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get description for officer type
|
|
||||||
*/
|
|
||||||
private getOfficerTypeDescription(type: string): string {
|
|
||||||
const baseStyle = "background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;";
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case OfficerTypes.ADMINISTRATOR:
|
|
||||||
return `
|
|
||||||
<div style="${baseStyle}">
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Administrator Role:</strong></p>
|
|
||||||
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
|
||||||
As an administrator, you have full access to manage officers, events, and system settings. You can add/remove other officers and access all administrative features.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
case OfficerTypes.EXECUTIVE:
|
|
||||||
return `
|
|
||||||
<div style="${baseStyle}">
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Executive Officer Role:</strong></p>
|
|
||||||
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
|
||||||
As an executive officer, you have leadership responsibilities and access to advanced features in the officer dashboard. You can manage events and participate in key decision-making.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
case OfficerTypes.GENERAL:
|
|
||||||
return `
|
|
||||||
<div style="${baseStyle}">
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>General Officer Role:</strong></p>
|
|
||||||
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
|
||||||
As a general officer, you have access to the officer dashboard and can help with event management, member engagement, and other organizational activities.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
case OfficerTypes.HONORARY:
|
|
||||||
return `
|
|
||||||
<div style="${baseStyle}">
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Honorary Officer Role:</strong></p>
|
|
||||||
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
|
||||||
As an honorary officer, you are recognized for your contributions to IEEE UCSD. You have access to officer resources and are part of our leadership community.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
case OfficerTypes.PAST:
|
|
||||||
return `
|
|
||||||
<div style="${baseStyle}">
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Past Officer Status:</strong></p>
|
|
||||||
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
|
||||||
Thank you for your service to IEEE UCSD! As a past officer, you maintain access to alumni resources and remain part of our leadership community.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
default:
|
|
||||||
return `
|
|
||||||
<div style="${baseStyle}">
|
|
||||||
<p style="margin: 0; font-size: 14px;"><strong>Officer Role:</strong></p>
|
|
||||||
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
|
||||||
Welcome to the IEEE UCSD officer team! You now have access to officer resources and can contribute to our organization's activities.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch notify multiple officers (for bulk operations)
|
|
||||||
*/
|
|
||||||
async notifyBulkRoleChanges(
|
|
||||||
notifications: OfficerRoleChangeEmailData[]
|
|
||||||
): Promise<{ successful: number; failed: number }> {
|
|
||||||
let successful = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
for (const notification of notifications) {
|
|
||||||
try {
|
|
||||||
const result = await this.sendRoleChangeNotification(notification);
|
|
||||||
if (result) {
|
|
||||||
successful++;
|
|
||||||
} else {
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a small delay to avoid rate limiting
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send bulk notification:', error);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { successful, failed };
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,203 +0,0 @@
|
||||||
# Email Notification System
|
|
||||||
|
|
||||||
This directory contains the email notification system for the IEEE UCSD reimbursement portal and event management system using Resend.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
Add the following environment variables to your `.env` file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# PocketBase Configuration
|
|
||||||
POCKETBASE_URL=https://pocketbase.ieeeucsd.org
|
|
||||||
|
|
||||||
# Resend API Configuration
|
|
||||||
RESEND_API_KEY=your_resend_api_key_here
|
|
||||||
|
|
||||||
# Email Configuration
|
|
||||||
FROM_EMAIL="IEEE UCSD <noreply@ieeeucsd.org>"
|
|
||||||
REPLY_TO_EMAIL="treasurer@ieeeucsd.org"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: This project uses Astro's standard environment variable pattern with `import.meta.env.VARIABLE_NAME`. No PUBLIC_ prefix is needed as these are used in API routes and server-side code.
|
|
||||||
|
|
||||||
### Getting a Resend API Key
|
|
||||||
|
|
||||||
1. Sign up for a [Resend account](https://resend.com)
|
|
||||||
2. Go to your dashboard and create a new API key
|
|
||||||
3. Add the API key to your environment variables
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Automatic Email Notifications
|
|
||||||
|
|
||||||
The system automatically sends emails for the following events:
|
|
||||||
|
|
||||||
#### Reimbursement System
|
|
||||||
1. **Reimbursement Submitted** - Confirmation email when a user submits a new reimbursement request
|
|
||||||
2. **Status Changes** - Notification when reimbursement status is updated (submitted, under review, approved, rejected, in progress, paid)
|
|
||||||
3. **Comments Added** - Notification when someone adds a public comment to a reimbursement
|
|
||||||
4. **Rejections with Reasons** - Detailed rejection notification including the specific reason for rejection
|
|
||||||
|
|
||||||
#### Event Management System
|
|
||||||
1. **Event Request Submitted** - Notification to coordinators@ieeeatucsd.org when a new event request is submitted
|
|
||||||
|
|
||||||
Note: Private comments are not sent via email to maintain privacy.
|
|
||||||
|
|
||||||
### Email Templates
|
|
||||||
|
|
||||||
All emails include:
|
|
||||||
- Professional IEEE UCSD branding
|
|
||||||
- Responsive design for mobile and desktop
|
|
||||||
- Clear status indicators with color coding
|
|
||||||
- Detailed information summary
|
|
||||||
- Next steps information
|
|
||||||
- Contact information for support
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### In React Components (Client-side)
|
|
||||||
|
|
||||||
#### Reimbursement Notifications
|
|
||||||
```typescript
|
|
||||||
import { EmailClient } from '../../../scripts/email/EmailClient';
|
|
||||||
|
|
||||||
// Send status change notification
|
|
||||||
await EmailClient.notifyStatusChange(reimbursementId, newStatus, previousStatus, userId);
|
|
||||||
|
|
||||||
// Send comment notification
|
|
||||||
await EmailClient.notifyComment(reimbursementId, comment, commentByUserId, isPrivate);
|
|
||||||
|
|
||||||
// Send submission confirmation
|
|
||||||
await EmailClient.notifySubmission(reimbursementId);
|
|
||||||
|
|
||||||
// Send rejection with reason (recommended for rejections)
|
|
||||||
await EmailClient.notifyRejection(reimbursementId, rejectionReason, previousStatus, userId);
|
|
||||||
|
|
||||||
// Send test email
|
|
||||||
await EmailClient.sendTestEmail('your-email@example.com');
|
|
||||||
|
|
||||||
// Alternative: Send rejection via notifyStatusChange with additionalContext
|
|
||||||
await EmailClient.notifyStatusChange(
|
|
||||||
reimbursementId,
|
|
||||||
'rejected',
|
|
||||||
previousStatus,
|
|
||||||
userId,
|
|
||||||
{ rejectionReason: 'Missing receipt for coffee purchase. Please resubmit with proper documentation.' }
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Event Request Notifications
|
|
||||||
```typescript
|
|
||||||
import { EmailClient } from '../../../scripts/email/EmailClient';
|
|
||||||
|
|
||||||
// Send event request submission notification to coordinators
|
|
||||||
await EmailClient.notifyEventRequestSubmission(eventRequestId);
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Route (Server-side)
|
|
||||||
|
|
||||||
The API route at `/api/email/send-reimbursement-notification` accepts POST requests with the following structure:
|
|
||||||
|
|
||||||
#### Reimbursement Notifications
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "status_change" | "comment" | "submission" | "test",
|
|
||||||
"reimbursementId": "string",
|
|
||||||
"newStatus": "string", // for status_change
|
|
||||||
"previousStatus": "string", // for status_change
|
|
||||||
"changedByUserId": "string", // for status_change
|
|
||||||
"comment": "string", // for comment
|
|
||||||
"commentByUserId": "string", // for comment
|
|
||||||
"isPrivate": boolean, // for comment
|
|
||||||
"additionalContext": {}, // for additional data
|
|
||||||
"authData": { // Authentication data for PocketBase access
|
|
||||||
"token": "string",
|
|
||||||
"model": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Event Request Notifications
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "event_request_submission",
|
|
||||||
"eventRequestId": "string",
|
|
||||||
"authData": { // Authentication data for PocketBase access
|
|
||||||
"token": "string",
|
|
||||||
"model": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The email system uses a client-server architecture for security and authentication:
|
|
||||||
|
|
||||||
- `EmailService.ts` - Core email service using Resend (server-side only)
|
|
||||||
- `ReimbursementEmailNotifications.ts` - High-level notification service (server-side only)
|
|
||||||
- `EmailClient.ts` - Client-side helper that calls the API with authentication
|
|
||||||
- `/api/email/send-reimbursement-notification.ts` - API route that handles server-side email sending with PocketBase authentication
|
|
||||||
|
|
||||||
### Authentication Flow
|
|
||||||
|
|
||||||
1. **Client-side**: `EmailClient` gets the current user's authentication token and model from the `Authentication` service
|
|
||||||
2. **API Request**: The auth data is sent to the server-side API route
|
|
||||||
3. **Server-side**: The API route authenticates with PocketBase using the provided auth data
|
|
||||||
4. **Database Access**: The authenticated PocketBase connection can access protected collections
|
|
||||||
5. **Email Sending**: Emails are sent using the Resend service with proper user data
|
|
||||||
|
|
||||||
This ensures that:
|
|
||||||
- API keys are never exposed to the client-side code
|
|
||||||
- Only authenticated users can trigger email notifications
|
|
||||||
- The server can access protected PocketBase collections
|
|
||||||
- Email operations respect user permissions and data security
|
|
||||||
|
|
||||||
## Email Recipients
|
|
||||||
|
|
||||||
- **Reimbursement notifications**: Sent to the user who submitted the reimbursement
|
|
||||||
- **Event request notifications**: Sent to coordinators@ieeeatucsd.org
|
|
||||||
- **Test emails**: Sent to the specified email address
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
Email failures are logged but do not prevent the main operations from completing. This ensures that reimbursement processing and event request submissions continue even if email delivery fails.
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- API keys are loaded from environment variables server-side only
|
|
||||||
- Authentication tokens are passed securely from client to server
|
|
||||||
- Email addresses are validated before sending
|
|
||||||
- Private comments are not sent via email (configurable)
|
|
||||||
- All emails include appropriate contact information
|
|
||||||
- PocketBase collection access respects authentication and permissions
|
|
||||||
|
|
||||||
## Event Request Email Notifications
|
|
||||||
|
|
||||||
### Event Request Submission
|
|
||||||
When a new event request is submitted, an email is automatically sent to the coordinators team.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await EmailClient.notifyEventRequestSubmission(eventRequestId);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Event Request Status Change
|
|
||||||
When an event request status is changed, an email is sent to coordinators.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await EmailClient.notifyEventRequestStatusChange(eventRequestId, previousStatus, newStatus, changedByUserId);
|
|
||||||
```
|
|
||||||
|
|
||||||
### PR Completion Notification
|
|
||||||
When PR materials are completed for an event request, an email is sent to the submitter notifying them to contact the internal team.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await EmailClient.notifyPRCompleted(eventRequestId);
|
|
||||||
```
|
|
||||||
|
|
||||||
This email includes:
|
|
||||||
- Confirmation that PR materials are completed
|
|
||||||
- Event details and information
|
|
||||||
- Instructions to contact the internal team for next steps
|
|
||||||
- Contact information for internal@ieeeucsd.org
|
|
|
@ -1,310 +0,0 @@
|
||||||
import { EmailService, type StatusChangeEmailData, type CommentEmailData, type ReimbursementEmailData } from './EmailService';
|
|
||||||
import { Get } from '../pocketbase/Get';
|
|
||||||
import type { User, Reimbursement, Receipt } from '../../schemas/pocketbase';
|
|
||||||
|
|
||||||
export class ReimbursementEmailNotifications {
|
|
||||||
private emailService: EmailService;
|
|
||||||
private get: Get;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.emailService = EmailService.getInstance();
|
|
||||||
this.get = Get.getInstance();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static instance: ReimbursementEmailNotifications | null = null;
|
|
||||||
|
|
||||||
public static getInstance(): ReimbursementEmailNotifications {
|
|
||||||
if (!ReimbursementEmailNotifications.instance) {
|
|
||||||
ReimbursementEmailNotifications.instance = new ReimbursementEmailNotifications();
|
|
||||||
}
|
|
||||||
return ReimbursementEmailNotifications.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send notification when reimbursement status changes
|
|
||||||
*/
|
|
||||||
async notifyStatusChange(
|
|
||||||
reimbursementId: string,
|
|
||||||
previousStatus: string,
|
|
||||||
newStatus: string,
|
|
||||||
changedByUserId?: string,
|
|
||||||
comment?: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Get reimbursement details
|
|
||||||
const reimbursement = await this.get.getOne<Reimbursement>('reimbursement', reimbursementId);
|
|
||||||
if (!reimbursement) {
|
|
||||||
console.error('Reimbursement not found:', reimbursementId);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get submitter user details
|
|
||||||
const user = await this.get.getOne<User>('users', reimbursement.submitted_by);
|
|
||||||
if (!user || !user.email) {
|
|
||||||
console.error('User not found or no email:', reimbursement.submitted_by);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get changed by user name if provided
|
|
||||||
let changedByName = 'System';
|
|
||||||
if (changedByUserId) {
|
|
||||||
try {
|
|
||||||
const changedByUser = await this.get.getOne<User>('users', changedByUserId);
|
|
||||||
changedByName = changedByUser?.name || 'Unknown User';
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Could not get changed by user name:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailData: StatusChangeEmailData = {
|
|
||||||
user,
|
|
||||||
reimbursement,
|
|
||||||
previousStatus,
|
|
||||||
newStatus,
|
|
||||||
changedBy: changedByName,
|
|
||||||
comment
|
|
||||||
};
|
|
||||||
|
|
||||||
return await this.emailService.sendStatusChangeEmail(emailData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send status change notification:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send notification when a comment is added to a reimbursement
|
|
||||||
*/
|
|
||||||
async notifyComment(
|
|
||||||
reimbursementId: string,
|
|
||||||
comment: string,
|
|
||||||
commentByUserId: string,
|
|
||||||
isPrivate: boolean = false
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Don't send emails for private comments (for now)
|
|
||||||
if (isPrivate) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get reimbursement details
|
|
||||||
const reimbursement = await this.get.getOne<Reimbursement>('reimbursement', reimbursementId);
|
|
||||||
if (!reimbursement) {
|
|
||||||
console.error('Reimbursement not found:', reimbursementId);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get submitter user details
|
|
||||||
const user = await this.get.getOne<User>('users', reimbursement.submitted_by);
|
|
||||||
if (!user || !user.email) {
|
|
||||||
console.error('User not found or no email:', reimbursement.submitted_by);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't send email if the commenter is the same as the submitter
|
|
||||||
if (commentByUserId === reimbursement.submitted_by) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get commenter user name
|
|
||||||
let commentByName = 'Unknown User';
|
|
||||||
try {
|
|
||||||
const commentByUser = await this.get.getOne<User>('users', commentByUserId);
|
|
||||||
commentByName = commentByUser?.name || 'Unknown User';
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Could not get commenter user name:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailData: CommentEmailData = {
|
|
||||||
user,
|
|
||||||
reimbursement,
|
|
||||||
comment,
|
|
||||||
commentBy: commentByName,
|
|
||||||
isPrivate
|
|
||||||
};
|
|
||||||
|
|
||||||
return await this.emailService.sendCommentEmail(emailData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send comment notification:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send submission confirmation email
|
|
||||||
*/
|
|
||||||
async notifySubmission(reimbursementId: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Get reimbursement details
|
|
||||||
const reimbursement = await this.get.getOne<Reimbursement>('reimbursement', reimbursementId);
|
|
||||||
if (!reimbursement) {
|
|
||||||
console.error('Reimbursement not found:', reimbursementId);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get submitter user details
|
|
||||||
const user = await this.get.getOne<User>('users', reimbursement.submitted_by);
|
|
||||||
if (!user || !user.email) {
|
|
||||||
console.error('User not found or no email:', reimbursement.submitted_by);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get receipt details if needed
|
|
||||||
let receipts: Receipt[] = [];
|
|
||||||
if (reimbursement.receipts && reimbursement.receipts.length > 0) {
|
|
||||||
try {
|
|
||||||
receipts = await Promise.all(
|
|
||||||
reimbursement.receipts.map(id => this.get.getOne<Receipt>('receipts', id))
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Could not load receipt details:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailData: ReimbursementEmailData = {
|
|
||||||
user,
|
|
||||||
reimbursement,
|
|
||||||
receipts
|
|
||||||
};
|
|
||||||
|
|
||||||
return await this.emailService.sendSubmissionConfirmation(emailData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send submission confirmation:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send specific status-based notifications with custom logic
|
|
||||||
*/
|
|
||||||
async notifyByStatus(
|
|
||||||
reimbursementId: string,
|
|
||||||
status: string,
|
|
||||||
previousStatus?: string,
|
|
||||||
triggeredByUserId?: string,
|
|
||||||
additionalContext?: Record<string, any>
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
switch (status) {
|
|
||||||
case 'submitted':
|
|
||||||
return await this.notifySubmission(reimbursementId);
|
|
||||||
|
|
||||||
case 'approved':
|
|
||||||
return await this.notifyStatusChange(
|
|
||||||
reimbursementId,
|
|
||||||
previousStatus || 'under_review',
|
|
||||||
status,
|
|
||||||
triggeredByUserId,
|
|
||||||
'Your reimbursement has been approved and will be processed for payment.'
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'rejected':
|
|
||||||
const rejectionReason = additionalContext?.rejectionReason;
|
|
||||||
return await this.notifyStatusChange(
|
|
||||||
reimbursementId,
|
|
||||||
previousStatus || 'under_review',
|
|
||||||
status,
|
|
||||||
triggeredByUserId,
|
|
||||||
rejectionReason ? `Rejection reason: ${rejectionReason}` : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'paid':
|
|
||||||
return await this.notifyStatusChange(
|
|
||||||
reimbursementId,
|
|
||||||
previousStatus || 'in_progress',
|
|
||||||
status,
|
|
||||||
triggeredByUserId,
|
|
||||||
'Your reimbursement has been completed. Please check your account for the payment.'
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'under_review':
|
|
||||||
case 'in_progress':
|
|
||||||
return await this.notifyStatusChange(
|
|
||||||
reimbursementId,
|
|
||||||
previousStatus || 'submitted',
|
|
||||||
status,
|
|
||||||
triggeredByUserId
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
console.log(`No specific notification handler for status: ${status}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to send notification for status ${status}:`, error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch notify multiple users (for administrative notifications)
|
|
||||||
*/
|
|
||||||
async notifyAdmins(
|
|
||||||
subject: string,
|
|
||||||
message: string,
|
|
||||||
reimbursementId?: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// This could be enhanced to get admin user emails from the officers table
|
|
||||||
// For now, we'll just log this functionality
|
|
||||||
console.log('Admin notification requested:', { subject, message, reimbursementId });
|
|
||||||
|
|
||||||
// TODO: Implement admin notification logic
|
|
||||||
// - Get list of admin users from officers table
|
|
||||||
// - Send email to all admins
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send admin notification:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test email functionality (useful for development)
|
|
||||||
*/
|
|
||||||
async testEmail(userEmail: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Create a test user object
|
|
||||||
const testUser: User = {
|
|
||||||
id: 'test-user',
|
|
||||||
created: new Date().toISOString(),
|
|
||||||
updated: new Date().toISOString(),
|
|
||||||
email: userEmail,
|
|
||||||
emailVisibility: true,
|
|
||||||
verified: true,
|
|
||||||
name: 'Test User'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create a test reimbursement object
|
|
||||||
const testReimbursement: Reimbursement = {
|
|
||||||
id: 'test-reimbursement',
|
|
||||||
created: new Date().toISOString(),
|
|
||||||
updated: new Date().toISOString(),
|
|
||||||
title: 'Test Reimbursement',
|
|
||||||
total_amount: 99.99,
|
|
||||||
date_of_purchase: new Date().toISOString(),
|
|
||||||
payment_method: 'Personal Credit Card',
|
|
||||||
status: 'submitted',
|
|
||||||
submitted_by: 'test-user',
|
|
||||||
additional_info: 'This is a test reimbursement for email functionality.',
|
|
||||||
receipts: [],
|
|
||||||
department: 'events'
|
|
||||||
};
|
|
||||||
|
|
||||||
const emailData: StatusChangeEmailData = {
|
|
||||||
user: testUser,
|
|
||||||
reimbursement: testReimbursement,
|
|
||||||
previousStatus: 'submitted',
|
|
||||||
newStatus: 'approved',
|
|
||||||
changedBy: 'Test Admin',
|
|
||||||
comment: 'This is a test email notification.'
|
|
||||||
};
|
|
||||||
|
|
||||||
return await this.emailService.sendStatusChangeEmail(emailData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send test email:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
22
src/styles/global.css
Normal file
22
src/styles/global.css
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
@import 'tailwindcss';
|
||||||
|
@plugin "daisyui";
|
||||||
|
@config '../../tailwind.config.mjs';
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -70,15 +70,6 @@ export default {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
|
||||||
require("tailwindcss-motion"),
|
|
||||||
require("tailwindcss-animated"),
|
|
||||||
require("daisyui"),
|
|
||||||
function ({ addVariant }) {
|
|
||||||
addVariant("in-view", "&.in-view");
|
|
||||||
},
|
|
||||||
heroui(),
|
|
||||||
],
|
|
||||||
daisyui: {
|
daisyui: {
|
||||||
themes: [
|
themes: [
|
||||||
{
|
{
|
||||||
|
|
Loading…
Reference in a new issue