Convert backends to Firebase and combine projects
24
RIT-EMS-main/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
RIT-EMS-main/frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
RIT-EMS-main/frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
15
RIT-EMS-main/frontend/excel_preview.csv
Normal file
@@ -0,0 +1,15 @@
|
||||
Timestamp,Date of the event (start date),Date of the event (End date),starting Time,ending Time,Venue,Department,Requirements [Row 1],ending Time.1,Name of the event,Name of the faculty coordinator,Name of the Guest,Email Address,"Any Other Requirements, Please mention",Name of the faculty coordinator.1,Date of the event (End date).1,Status
|
||||
2024-03-08 14:26:41.487,3/22/0024,,15:00:00,15:00:00,Wozniak Auditorium,RSB,"Memento, Projector, Mike, Refreshments",15:00:00,Biz-analytix guest lecture,,Mr Gokul Palani,badri.toppur@gmail.com,"pointer. cab for pickup and drop-off. Lunch for seven club members, and boquet.",,,
|
||||
2024-07-05 12:03:24.523,1983-12-07 00:00:00,,15:00:00,15:00:00,Wozniak Auditorium,RIT Placement,"Memento, Projector, Mike, White Board, Refreshments, Lunch, Breakfast, Guest Transport (Transport Form to be submitted)",15:00:00,JP,,JP GANTHI,training@ritchennai.edu.in,,Pandithurai ,,
|
||||
2024-01-23 00:00:00.000,2024-01-23 00:00:00,,11:00:00,11:00:00,Wozniak Auditorium,RSB,"2. Audio/Video, PA system, Uninterrupted power supply
|
||||
3.LED Screen
|
||||
4. Flower decoration
|
||||
5. Computer lab with 65 systems with webcam and wifi connection.
|
||||
6.Board Room for two days (stationery required)
|
||||
7. Guest hospitality(Lunch & Refreshments for 8 people - Tea,Coffee, Juice, Cookies, Special Lunch)
|
||||
8. Bouquets - 5
|
||||
9. Transportation for Guest - (would intimate numbers once confirmed)",11:00:00,Mindsprint Campus Placement,,,,,,,
|
||||
,2024-01-23 00:00:00,,15:00:00,15:00:00,Wozniak Auditorium,RSB,"1. Wozniak auditorium
|
||||
2. Lunch - 4 (2 guests, Director and Faculty)
|
||||
3. Memento - 2 guests",15:00:00,"guest lecture from CII, Logistics Center of Excellence, IIT Madras",,,,,,,
|
||||
2024-01-22 12:33:45.130,2024-01-23 00:00:00,,15:00:00,15:00:00,Steve Jobs 1st Floor,RIT Mech,"Projector, White Board",15:00:00,Awareness session on Start - up and chatGpt,,"Internal faculties (Dr. V.M. Gopinath, Dr.M.Shaju and Dr.M.Dinesh Babu)",pragadish.n@ritchennai.edu.in,,,,
|
||||
|
36
RIT-EMS-main/frontend/fix_urls.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
api_import = "import { API_BASE_URL } from '../../lib/config';"
|
||||
# Need to adjust depth based on file location
|
||||
# But I can just use a relative path that works for most components
|
||||
|
||||
def fix_file(path):
|
||||
with open(path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Calculate depth for import
|
||||
rel_path = os.path.relpath('src/lib/config.ts', os.path.dirname(path))
|
||||
rel_path = rel_path.replace('.ts', '').replace('\\', '/')
|
||||
if not rel_path.startswith('.'):
|
||||
rel_path = './' + rel_path
|
||||
|
||||
import_line = f"import {{ API_BASE_URL }} from '{rel_path}';"
|
||||
|
||||
# Replace the messy strings
|
||||
new_content = content.replace("'http://' + window.location.hostname + ':8080", "API_BASE_URL")
|
||||
new_content = new_content.replace("`http://' + window.location.hostname + ':8080", "`${API_BASE_URL}")
|
||||
|
||||
if new_content != content:
|
||||
# Add import if not present
|
||||
if 'API_BASE_URL' in new_content and 'import { API_BASE_URL }' not in new_content:
|
||||
new_content = import_line + "\n" + new_content
|
||||
|
||||
with open(path, 'w') as f:
|
||||
f.write(new_content)
|
||||
print(f"Fixed {path}")
|
||||
|
||||
for root, dirs, files in os.walk('src'):
|
||||
for file in files:
|
||||
if file.endswith(('.ts', '.tsx')):
|
||||
fix_file(os.path.join(root, file))
|
||||
13
RIT-EMS-main/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<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, initial-scale=1.0" />
|
||||
<title>RIT EMS - Event Management System</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4579
RIT-EMS-main/frontend/package-lock.json
generated
Normal file
43
RIT-EMS-main/frontend/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/uuid": "^10.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"firebase": "^12.15.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"uuid": "^14.0.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"postcss": "^8.5.10",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.9"
|
||||
}
|
||||
}
|
||||
6
RIT-EMS-main/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
RIT-EMS-main/frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
RIT-EMS-main/frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
187
RIT-EMS-main/frontend/src/App.css
Normal file
@@ -0,0 +1,187 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
&>div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
175
RIT-EMS-main/frontend/src/App.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { API_BASE_URL } from './lib/config';
|
||||
import React, { useState } from 'react';
|
||||
import { DashboardLayout } from './components/DashboardLayout';
|
||||
import { InstitutionalChecklist } from './components/dashboard/InstitutionalChecklist';
|
||||
import { EventProposalForm } from './components/dashboard/EventProposalForm';
|
||||
import { AllEvents } from './components/dashboard/AllEvents';
|
||||
import { ApprovalsView } from './components/dashboard/ApprovalsView';
|
||||
import { UserManagement } from './components/dashboard/UserManagement';
|
||||
import { ClassManagement } from './components/dashboard/ClassManagement';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { StatusTimeline, type Event } from './components/dashboard/EventStatusTimeline';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { InstitutionalCalendar } from './components/dashboard/InstitutionalCalendar';
|
||||
import { VenueTimeline } from './components/dashboard/VenueTimeline';
|
||||
import { EventHistory } from './components/dashboard/EventHistory';
|
||||
import { ClubInstitutionalChecklist } from './components/dashboard/ClubInstitutionalChecklist';
|
||||
import { AutomationView } from './components/dashboard/AutomationView';
|
||||
import { Overview } from './components/dashboard/Overview';
|
||||
import { ExcelImport } from './components/dashboard/ExcelImport';
|
||||
|
||||
const AppContent: React.FC = () => {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const isClubAuthorized = user?.isClubCoordinator || user?.role === 'ADMIN';
|
||||
const [activeItem, setActiveItem] = useState('dashboard');
|
||||
const [userEvents, setUserEvents] = useState<Event[]>([]);
|
||||
const [preFillData, setPreFillData] = useState<any>(null);
|
||||
|
||||
const handleIncompleteClick = (data: any) => {
|
||||
setPreFillData(data);
|
||||
setActiveItem('propose');
|
||||
};
|
||||
|
||||
const handleCalendarPropose = (date: string) => {
|
||||
setPreFillData({ startDate: `${date}T09:00` });
|
||||
setActiveItem('propose');
|
||||
};
|
||||
|
||||
const handleClubEventClick = (data: any) => {
|
||||
setPreFillData(data);
|
||||
setActiveItem('propose');
|
||||
};
|
||||
|
||||
const handleEditEvent = (event: any) => {
|
||||
setPreFillData({
|
||||
...event,
|
||||
eventName: event.title,
|
||||
venue: event.location,
|
||||
socialProfile: event.guestSocialProfile,
|
||||
isEditMode: true
|
||||
});
|
||||
setActiveItem('propose');
|
||||
};
|
||||
|
||||
const handleSidebarClick = (item: string) => {
|
||||
if (item === 'propose') setPreFillData(null);
|
||||
setActiveItem(item);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleNav = (e: any) => setActiveItem(e.detail);
|
||||
window.addEventListener('navigate', handleNav);
|
||||
return () => window.removeEventListener('navigate', handleNav);
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && activeItem === 'dashboard') {
|
||||
fetch(API_BASE_URL + '/api/events')
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (Array.isArray(data)) {
|
||||
let filtered = data;
|
||||
if (user?.role === 'HOD') {
|
||||
filtered = data.filter((e: Event) => e.department === user?.department);
|
||||
} else if (user?.role === 'FACULTY') {
|
||||
filtered = data.filter((e: Event) => e.proposer?.email === user?.email);
|
||||
}
|
||||
setUserEvents(filtered);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Failed to fetch dashboard events:", err));
|
||||
}
|
||||
}, [isAuthenticated, activeItem, user?.email, user?.role, user?.department]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="login"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<LoginPage />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
const isDashboard = (
|
||||
<Overview
|
||||
userEvents={userEvents}
|
||||
onNavigate={handleSidebarClick}
|
||||
onIncompleteClick={handleIncompleteClick}
|
||||
onCalendarPropose={handleCalendarPropose}
|
||||
onClubEventClick={handleClubEventClick}
|
||||
onEditEvent={handleEditEvent}
|
||||
/>
|
||||
);
|
||||
|
||||
switch (activeItem) {
|
||||
case 'user-management':
|
||||
return user?.role === 'ADMIN' ? <UserManagement /> : isDashboard;
|
||||
case 'approvals':
|
||||
return (user?.role === 'HOD' || user?.role === 'PRINCIPAL') ? <ApprovalsView /> : isDashboard;
|
||||
case 'clubs':
|
||||
return isClubAuthorized ? <ClubInstitutionalChecklist onEventClick={handleClubEventClick} /> : isDashboard;
|
||||
case 'events':
|
||||
return <AllEvents onEditEvent={handleEditEvent} />;
|
||||
case 'propose':
|
||||
return <EventProposalForm initialData={preFillData} />;
|
||||
case 'checklist':
|
||||
return <InstitutionalChecklist isGlobalView={true} onIncompleteClick={handleIncompleteClick} />;
|
||||
case 'history':
|
||||
return <EventHistory />;
|
||||
case 'classes':
|
||||
return user?.role === 'ADMIN' ? <ClassManagement /> : isDashboard;
|
||||
case 'automation':
|
||||
return (user?.role === 'ADMIN' || user?.role === 'PRINCIPAL' || user?.role === 'HOD') ? <AutomationView /> : isDashboard;
|
||||
case 'import-excel':
|
||||
return <ExcelImport />;
|
||||
case 'dashboard':
|
||||
return isDashboard;
|
||||
default:
|
||||
return isDashboard;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
activeItem={activeItem}
|
||||
onItemClick={handleSidebarClick}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeItem}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{renderContent()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
BIN
RIT-EMS-main/frontend/src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
RIT-EMS-main/frontend/src/assets/images/college-logo.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
RIT-EMS-main/frontend/src/assets/images/ritchennai.jpg
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
RIT-EMS-main/frontend/src/assets/images/rsb_logo.png
Normal file
|
After Width: | Height: | Size: 250 KiB |
1
RIT-EMS-main/frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
RIT-EMS-main/frontend/src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
214
RIT-EMS-main/frontend/src/components/DashboardLayout.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Search, Bell, Settings, LogOut, User as UserIcon, Plus, Calendar, ClipboardList, CheckCircle, Users, BookOpen, Zap, Clock, History as HistoryIcon, LayoutGrid, FileSpreadsheet } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { API_BASE_URL } from '../lib/config';
|
||||
import { cn } from '../lib/utils';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
activeItem: string;
|
||||
onItemClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export const DashboardLayout: React.FC<DashboardLayoutProps> = ({
|
||||
children,
|
||||
activeItem,
|
||||
onItemClick
|
||||
}) => {
|
||||
const { user, logout } = useAuth();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearchOpen && events.length === 0) {
|
||||
fetch(API_BASE_URL + '/api/events')
|
||||
.then(res => res.json())
|
||||
.then(data => setEvents(Array.isArray(data) ? data : []))
|
||||
.catch(err => console.error('Search fetch failed:', err));
|
||||
}
|
||||
}, [isSearchOpen]);
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutGrid, description: 'Main overview and stats' },
|
||||
{ id: 'approvals', label: 'Approvals', icon: CheckCircle, description: 'Review pending requests', roles: ['HOD', 'PRINCIPAL', 'ADMIN'] },
|
||||
{ id: 'events', label: 'All Events', icon: ClipboardList, description: 'Browse institution events' },
|
||||
{ id: 'checklist', label: 'Checklist', icon: Calendar, description: 'Audit and compliance view' },
|
||||
{ id: 'history', label: 'History', icon: HistoryIcon, description: 'Event ledger and audit trail' },
|
||||
{ id: 'user-management', label: 'Manage Users', icon: Users, description: 'User roles and access', roles: ['ADMIN'] },
|
||||
{ id: 'classes', label: 'Classes', icon: BookOpen, description: 'Academic structure', roles: ['ADMIN'] },
|
||||
{ id: 'import-excel', label: 'Import Excel', icon: FileSpreadsheet, description: 'Bulk upload from response sheets', roles: ['ADMIN', 'PRINCIPAL'] },
|
||||
{ id: 'automation', label: 'Automation', icon: Zap, description: 'Smart scheduling tools', roles: ['ADMIN', 'PRINCIPAL', 'HOD'] },
|
||||
].filter(item => !item.roles || item.roles.includes(user?.role || ''));
|
||||
|
||||
const filteredNav = searchQuery.length > 1
|
||||
? navItems.filter(item => item.label.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: [];
|
||||
|
||||
const filteredEvents = searchQuery.length > 1
|
||||
? events.filter(e =>
|
||||
e.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
e.department?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
).slice(0, 5)
|
||||
: [];
|
||||
|
||||
const handleResultClick = (id: string, type: 'nav' | 'event') => {
|
||||
if (type === 'nav') {
|
||||
onItemClick(id);
|
||||
} else {
|
||||
// For events, we'll navigate to 'events' and we could potentially filter there
|
||||
onItemClick('events');
|
||||
}
|
||||
setSearchQuery('');
|
||||
setIsSearchOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#FDFDFE]">
|
||||
<Sidebar
|
||||
activeItem={activeItem}
|
||||
onItemClick={onItemClick}
|
||||
/>
|
||||
<main className="ml-[18rem] min-h-screen transition-all duration-500 pr-8">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-40 bg-[#FDFDFE]/80 backdrop-blur-md py-6 flex items-center justify-between gap-8">
|
||||
<div className="flex-1 max-w-xl relative group" ref={searchRef}>
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4.5 h-4.5 text-slate-400 group-focus-within:text-brand-indigo transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setIsSearchOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsSearchOpen(true)}
|
||||
placeholder="Search events, pages, or tools..."
|
||||
className="w-full bg-white border border-slate-200/60 rounded-[1.25rem] py-3 pl-12 pr-4 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-brand-indigo/10 focus:border-brand-indigo transition-all shadow-sm"
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{isSearchOpen && (filteredNav.length > 0 || filteredEvents.length > 0) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
className="absolute top-full left-0 right-0 mt-3 bg-white/95 backdrop-blur-xl border border-slate-100 rounded-[2rem] shadow-[0_20px_50px_rgba(0,0,0,0.1)] overflow-hidden z-50 p-2"
|
||||
>
|
||||
{filteredNav.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<p className="px-4 py-2 text-[10px] font-black uppercase tracking-widest text-slate-400">Navigation</p>
|
||||
{filteredNav.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleResultClick(item.id, 'nav')}
|
||||
className="w-full flex items-center gap-4 p-3 hover:bg-slate-50 rounded-2xl transition-all text-left group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-glow flex items-center justify-center text-brand-indigo group-hover:bg-brand-indigo group-hover:text-white transition-all">
|
||||
<item.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-black text-brand-navy">{item.label}</p>
|
||||
<p className="text-[10px] font-medium text-slate-400">{item.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredEvents.length > 0 && (
|
||||
<div>
|
||||
<p className="px-4 py-2 text-[10px] font-black uppercase tracking-widest text-slate-400">Recent Events</p>
|
||||
{filteredEvents.map(event => (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => handleResultClick(event.id, 'event')}
|
||||
className="w-full flex items-center gap-4 p-3 hover:bg-slate-50 rounded-2xl transition-all text-left group"
|
||||
>
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center transition-all",
|
||||
event.status === 'APPROVED' ? "bg-emerald-50 text-emerald-600" :
|
||||
event.status === 'PENDING' || event.status === 'REQUESTED' ? "bg-amber-50 text-amber-600" : "bg-slate-50 text-slate-400"
|
||||
)}>
|
||||
<ClipboardList className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-black text-brand-navy truncate max-w-[250px]">{event.title}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-slate-400">{event.department}</span>
|
||||
<span className={cn(
|
||||
"text-[8px] font-black uppercase px-1.5 py-0.5 rounded",
|
||||
event.status === 'APPROVED' ? "bg-emerald-50 text-emerald-600" :
|
||||
event.status === 'PENDING' || event.status === 'REQUESTED' ? "bg-amber-50 text-amber-600" : "bg-slate-100 text-slate-400"
|
||||
)}>{event.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Clock className="w-3.5 h-3.5 text-slate-300" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => onItemClick('propose')}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-brand-indigo text-white rounded-[1.25rem] text-sm font-black transition-all hover:shadow-lg hover:shadow-brand-indigo/20 hover:scale-[1.02]"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Event
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1 bg-white p-1 rounded-2xl border border-slate-100 shadow-sm">
|
||||
<button className="p-2.5 rounded-xl hover:bg-slate-50 transition-all text-slate-500 relative">
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-2.5 right-2.5 w-2 h-2 bg-red-500 rounded-full border-2 border-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 bg-white pl-1 pr-4 py-1 rounded-[1.5rem] shadow-sm border border-slate-100">
|
||||
<div className="w-10 h-10 rounded-full bg-brand-glow flex items-center justify-center overflow-hidden border border-brand-indigo/10">
|
||||
<UserIcon className="w-5 h-5 text-brand-indigo" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-black text-brand-navy leading-none mb-0.5">
|
||||
{user?.fullName}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-brand-indigo/60 uppercase tracking-wider">
|
||||
{user?.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-3 rounded-2xl bg-white border border-slate-100 text-slate-400 hover:text-red-500 hover:border-red-100 hover:bg-red-50 transition-all shadow-sm"
|
||||
title="Sign Out"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="pb-10">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
104
RIT-EMS-main/frontend/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
LayoutGrid,
|
||||
ClipboardList,
|
||||
CheckSquare,
|
||||
History as HistoryIcon,
|
||||
Zap,
|
||||
BookOpen,
|
||||
PlusCircle,
|
||||
ChevronRight,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
Calendar,
|
||||
HelpCircle,
|
||||
ExternalLink,
|
||||
CheckCircle
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { motion } from 'framer-motion';
|
||||
import ritLogo from '../assets/images/college-logo.png';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
interface SidebarProps {
|
||||
activeItem: string;
|
||||
onItemClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ activeItem, onItemClick }) => {
|
||||
const { user } = useAuth();
|
||||
const isClubAuthorized = user?.isClubCoordinator || user?.role === 'ADMIN';
|
||||
|
||||
const menuItems = [
|
||||
{ icon: LayoutGrid, label: 'Dashboard', id: 'dashboard' },
|
||||
...((user?.role === 'HOD' || user?.role === 'PRINCIPAL') ? [{ icon: CheckCircle, label: 'Approvals', id: 'approvals' }] : []),
|
||||
...(isClubAuthorized ? [{ icon: ShieldCheck, label: 'Clubs', id: 'clubs' }] : []),
|
||||
{ icon: ClipboardList, label: 'All Events', id: 'events' },
|
||||
{ icon: CheckSquare, label: 'Checklist', id: 'checklist' },
|
||||
{ icon: HistoryIcon, label: 'History', id: 'history' },
|
||||
...((user?.role === 'ADMIN' || user?.role === 'PRINCIPAL' || user?.role === 'HOD') ? [{ icon: Zap, label: 'Automation', id: 'automation' }] : []),
|
||||
...(user?.role === 'ADMIN' ? [{ icon: BookOpen, label: 'Classes', id: 'classes' }] : []),
|
||||
...(user?.role === 'ADMIN' ? [{ icon: Users, label: 'Manage Users', id: 'user-management' }] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="floating-sidebar">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 mb-8 px-2">
|
||||
<img src={ritLogo} alt="College Logo" className="h-14 w-auto object-contain" />
|
||||
</div>
|
||||
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1.5 overflow-y-auto custom-scrollbar pr-1">
|
||||
<p className="px-4 text-[10px] font-black uppercase tracking-widest text-slate-400 mb-4">Main Menu</p>
|
||||
{menuItems.map((item) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
whileHover={{ x: 4 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => onItemClick(item.id)}
|
||||
className={cn(
|
||||
"sidebar-item group relative",
|
||||
activeItem === item.id && "sidebar-item-active"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"p-2 rounded-xl transition-all",
|
||||
activeItem === item.id ? "bg-white shadow-sm" : "bg-transparent group-hover:bg-white/50"
|
||||
)}>
|
||||
<item.icon className="w-4.5 h-4.5" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="flex-1 text-sm">{item.label}</span>
|
||||
{activeItem === item.id && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-brand-indigo" />
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
|
||||
|
||||
{/* Footer Build Info */}
|
||||
<div className="mt-auto px-4 pt-6 border-t border-slate-50">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 bg-brand-glow text-brand-indigo rounded-full border border-brand-indigo/10 backdrop-blur-sm group/beta hover:bg-brand-indigo hover:text-white transition-all duration-300 cursor-default">
|
||||
<div className="w-1 h-1 bg-brand-indigo rounded-full group-hover/beta:bg-white animate-pulse" />
|
||||
<span className="text-[9px] font-black uppercase tracking-[0.2em]">Beta</span>
|
||||
</div>
|
||||
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-slate-300">
|
||||
v0.1.5
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-emerald-500/70">
|
||||
Live Systems
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
234
RIT-EMS-main/frontend/src/components/dashboard/AllEvents.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { API_BASE_URL } from '../../lib/config';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Calendar,
|
||||
MapPin,
|
||||
Building2,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Search,
|
||||
MoreVertical,
|
||||
FileSpreadsheet
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { StatusTimeline, type Event } from './EventStatusTimeline';
|
||||
import { Pagination } from './Pagination';
|
||||
|
||||
interface AllEventsProps {
|
||||
onEditEvent?: (event: any) => void;
|
||||
}
|
||||
|
||||
export const AllEvents: React.FC<AllEventsProps> = ({ onEditEvent }) => {
|
||||
const { user } = useAuth();
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, []);
|
||||
|
||||
const parseDate = (dateSource: any): Date | null => {
|
||||
if (!dateSource) return null;
|
||||
if (typeof dateSource === 'string') return new Date(dateSource);
|
||||
if (Array.isArray(dateSource)) {
|
||||
return new Date(dateSource[0], dateSource[1] - 1, dateSource[2], dateSource[3] || 0, dateSource[4] || 0);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/api/events');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setEvents(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch events:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: Event['status']) => {
|
||||
switch (status) {
|
||||
case 'REQUESTED': return 'bg-brand-glow text-brand-indigo border-brand-indigo/20';
|
||||
case 'PENDING_PR': return 'bg-amber-50 text-amber-600 border-amber-100';
|
||||
case 'APPROVED': return 'bg-emerald-50 text-emerald-600 border-emerald-100';
|
||||
case 'COMPLETED': return 'bg-slate-100 text-slate-600 border-slate-200';
|
||||
case 'CANCELLED': return 'bg-red-50 text-red-600 border-red-100';
|
||||
default: return 'bg-slate-50 text-slate-500 border-slate-100';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
const filteredEvents = events
|
||||
.filter(event =>
|
||||
event.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
event.department.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a.updatedAt || a.createdAt || 0).getTime();
|
||||
const dateB = new Date(b.updatedAt || b.createdAt || 0).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filteredEvents.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const currentEvents = filteredEvents.slice(startIndex, startIndex + itemsPerPage);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header & Controls */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-text-dark tracking-tight">All Events</h2>
|
||||
<p className="text-text-muted font-medium">Overview of all institutional activities and their status.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter events..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="bg-white border border-slate-200 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-brand-indigo/10 focus:border-brand-indigo transition-all premium-shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
{(user?.role === 'ADMIN' || user?.role === 'PRINCIPAL') && (
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('navigate', { detail: 'import-excel' }))}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-glow text-brand-indigo rounded-xl hover:bg-brand-indigo hover:text-white transition-all premium-shadow-sm border border-brand-indigo/10 text-sm font-black uppercase tracking-widest"
|
||||
>
|
||||
<FileSpreadsheet className="w-4 h-4" />
|
||||
Import
|
||||
</button>
|
||||
)}
|
||||
<button className="p-2 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-all premium-shadow-sm">
|
||||
<Filter className="w-5 h-5 text-text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="bg-white rounded-[2.5rem] border border-slate-100 premium-shadow overflow-hidden flex flex-col">
|
||||
{isLoading ? (
|
||||
<div className="p-20 text-center text-text-muted italic">Loading events...</div>
|
||||
) : filteredEvents.length === 0 ? (
|
||||
<div className="p-20 text-center">
|
||||
<div className="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Calendar className="w-8 h-8 text-slate-300" />
|
||||
</div>
|
||||
<p className="text-text-muted font-medium italic">No events found matching your search.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-50">
|
||||
<th className="px-8 py-6 text-[10px] font-black uppercase tracking-[0.2em] text-text-muted">Event Details</th>
|
||||
<th className="px-8 py-6 text-[10px] font-black uppercase tracking-[0.2em] text-text-muted">Date & Venue</th>
|
||||
<th className="px-8 py-6 text-[10px] font-black uppercase tracking-[0.2em] text-text-muted">Department</th>
|
||||
<th className="px-8 py-6 text-[10px] font-black uppercase tracking-[0.2em] text-text-muted">Status</th>
|
||||
<th className="px-8 py-6 text-[10px] font-black uppercase tracking-[0.2em] text-text-muted text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{currentEvents.map((event, idx) => (
|
||||
<motion.tr
|
||||
key={event.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="group hover:bg-slate-50/50 transition-colors"
|
||||
>
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-text-dark text-sm group-hover:text-brand-indigo transition-colors">{event.title}</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-text-muted mt-1">{event.type} • {event.institution}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-text-secondary">
|
||||
<Clock className="w-3.5 h-3.5 text-brand-indigo opacity-60" />
|
||||
{parseDate(event.startDate) ? parseDate(event.startDate)!.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }) : 'N/A'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-text-muted uppercase tracking-wider">
|
||||
<MapPin className="w-3.5 h-3.5 text-brand-indigo opacity-60" />
|
||||
{event.location}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-brand-glow flex items-center justify-center text-[10px] font-black text-brand-indigo">
|
||||
{event.department.substring(0, 2)}
|
||||
</div>
|
||||
<span className="text-xs font-bold text-text-secondary">{event.department}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
{user?.role === 'FACULTY' && event.proposer?.email === user?.email ? (
|
||||
<StatusTimeline event={event} compact />
|
||||
) : (
|
||||
<span className={cn(
|
||||
"inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest border",
|
||||
getStatusColor(event.status)
|
||||
)}>
|
||||
{event.status === 'PENDING_PR' ? 'PENDING PRINCIPAL' : event.status}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-8 py-6 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{(user?.role === 'ADMIN' || (event.status === 'REQUESTED' && event.proposer?.email === user?.email)) && onEditEvent && (
|
||||
<button
|
||||
onClick={() => onEditEvent(event)}
|
||||
className="px-4 py-2 bg-slate-50 text-brand-indigo hover:bg-brand-indigo hover:text-white rounded-xl text-[9px] font-black uppercase tracking-widest transition-all border border-brand-indigo/10"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<button className="p-2 rounded-lg hover:bg-white hover:premium-shadow transition-all text-slate-300 hover:text-brand-indigo">
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalItems={filteredEvents.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={(val) => {
|
||||
setItemsPerPage(val);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
itemsPerPageOptions={[5, 10, 20, 50]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
417
RIT-EMS-main/frontend/src/components/dashboard/ApprovalsView.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
import { API_BASE_URL } from '../../lib/config';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
User,
|
||||
Building2,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
ShieldCheck,
|
||||
MapPin,
|
||||
Wallet,
|
||||
Users,
|
||||
Layers,
|
||||
Ticket,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
Heart,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Pagination } from './Pagination';
|
||||
|
||||
interface Event {
|
||||
id: number;
|
||||
title: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
type: string;
|
||||
institution: string;
|
||||
department: string;
|
||||
academicYears: string[];
|
||||
status: string;
|
||||
location: string;
|
||||
proposer?: {
|
||||
fullName: string;
|
||||
department: string;
|
||||
};
|
||||
budget: number;
|
||||
hasRegistrationFee: boolean;
|
||||
registrationFee: number;
|
||||
category: string;
|
||||
targetedSections: string[];
|
||||
conflictMessage?: string;
|
||||
description?: string;
|
||||
sponsors?: string[];
|
||||
rejectionReason?: string;
|
||||
}
|
||||
|
||||
export const ApprovalsView: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [processingId, setProcessingId] = useState<number | null>(null);
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(5);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, []);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/api/events');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Filter based on role
|
||||
if (user?.role === 'HOD') {
|
||||
setEvents(data.filter((e: Event) => e.status === 'REQUESTED' && e.department === user.department));
|
||||
} else if (user?.role === 'PRINCIPAL') {
|
||||
setEvents(data.filter((e: Event) => e.status === 'PENDING_PR'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch events:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = async (id: number, action: 'approve' | 'reject') => {
|
||||
let reason = '';
|
||||
if (action === 'reject') {
|
||||
reason = window.prompt('Please enter a rejection reason:') || '';
|
||||
if (!reason) return;
|
||||
}
|
||||
|
||||
setProcessingId(id);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/events/${id}/${action}?userId=${user?.id}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: action === 'reject' ? JSON.stringify({ reason }) : undefined
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setEvents(events.filter(e => e.id !== id));
|
||||
setSelectedEvent(null);
|
||||
} else {
|
||||
const err = await response.json();
|
||||
alert(err.message || 'Operation failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${action} event:`, error);
|
||||
} finally {
|
||||
setProcessingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedEvents = useMemo(() => {
|
||||
return [...events].sort((a: any, b: any) => {
|
||||
const dateA = new Date(a.updatedAt || a.createdAt || 0).getTime();
|
||||
const dateB = new Date(b.updatedAt || b.createdAt || 0).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
}, [events]);
|
||||
|
||||
const currentEvents = sortedEvents.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-text-dark tracking-tight">Pending Approvals</h2>
|
||||
<p className="text-text-muted font-medium">Review and take action on event proposals from your {user?.role === 'HOD' ? 'department' : 'institution'}.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-indigo"></div>
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="bg-white rounded-[2.5rem] p-20 text-center border border-slate-100 premium-shadow">
|
||||
<CheckCircle className="w-16 h-16 text-emerald-500 mx-auto mb-6 opacity-20" />
|
||||
<h3 className="text-xl font-black text-text-dark mb-2">Queue is Empty</h3>
|
||||
<p className="text-text-muted italic">All proposals have been processed. Great work!</p>
|
||||
</div>
|
||||
) : (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{currentEvents.map((event) => (
|
||||
<motion.div
|
||||
key={event.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
className={cn(
|
||||
"bg-white rounded-[2rem] p-6 border transition-all flex flex-col md:flex-row items-center justify-between gap-6 cursor-pointer group",
|
||||
event.conflictMessage ? "border-red-200 bg-red-50/30" : "border-slate-100 hover:premium-shadow"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-6 w-full md:w-auto">
|
||||
<div className={cn(
|
||||
"w-16 h-16 rounded-2xl flex items-center justify-center font-black text-xl shadow-lg",
|
||||
event.conflictMessage ? "bg-red-500 text-white" : "bg-brand-glow text-brand-indigo"
|
||||
)}>
|
||||
{event.title[0]}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{event.category === 'CLUB' && (
|
||||
<span className="px-2 py-0.5 bg-emerald-50 text-emerald-600 rounded-md text-[8px] font-black uppercase tracking-widest border border-emerald-100">
|
||||
Institutional Club
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||
{event.type}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-text-dark leading-tight mb-2 group-hover:text-brand-indigo transition-colors">{event.title}</h3>
|
||||
|
||||
{event.conflictMessage ? (
|
||||
<div className="flex items-center gap-2 text-red-500 text-[10px] font-black uppercase tracking-widest mt-1">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
{event.conflictMessage}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center md:justify-start gap-4 text-xs font-bold text-text-muted">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<User className="w-3.5 h-3.5" />
|
||||
{event.proposer?.fullName || 'Faculty Member'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{new Date(event.startDate).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 w-full md:w-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => handleAction(event.id, 'reject')}
|
||||
disabled={processingId === event.id}
|
||||
className="flex-1 md:flex-none flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-red-50 text-red-600 hover:bg-red-100 transition-all font-black text-[10px] uppercase tracking-widest border border-red-100/50"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(event.id, 'approve')}
|
||||
disabled={processingId === event.id || !!event.conflictMessage}
|
||||
className={cn(
|
||||
"flex-1 md:flex-none flex items-center justify-center gap-2 px-8 py-3 rounded-xl transition-all font-black text-[10px] uppercase tracking-widest premium-shadow",
|
||||
event.conflictMessage
|
||||
? "bg-slate-200 text-slate-400 cursor-not-allowed"
|
||||
: "bg-brand-navy text-white hover:scale-105"
|
||||
)}
|
||||
>
|
||||
{processingId === event.id ? 'Processing...' : (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Approve
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{events.length > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalItems={events.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={(val) => {
|
||||
setItemsPerPage(val);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
itemsPerPageOptions={[5, 10, 15, 20]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Detail Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedEvent && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setSelectedEvent(null)}
|
||||
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="relative w-full max-w-2xl bg-white rounded-[2.5rem] premium-shadow overflow-hidden max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<div className={cn(
|
||||
"p-8 text-white flex justify-between items-start",
|
||||
selectedEvent.conflictMessage ? "bg-red-600" : "bg-brand-navy"
|
||||
)}>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-white/60">Proposal Details</span>
|
||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-white/60">{selectedEvent.institution}</span>
|
||||
{selectedEvent.category === 'CLUB' && (
|
||||
<>
|
||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-400">Institutional Club Event</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-2xl font-black tracking-tight">{selectedEvent.title}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedEvent(null)}
|
||||
className="p-2 hover:bg-white/10 rounded-xl transition-all"
|
||||
>
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
{/* Description Section */}
|
||||
{selectedEvent.description && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
Event Description
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-dark leading-relaxed bg-slate-50 p-4 rounded-2xl border border-slate-100">
|
||||
{selectedEvent.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
Schedule
|
||||
</div>
|
||||
<p className="text-sm font-bold text-text-dark">
|
||||
{new Date(selectedEvent.startDate).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
Venue
|
||||
</div>
|
||||
<p className="text-sm font-bold text-text-dark">{selectedEvent.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
|
||||
<User className="w-3.5 h-3.5" />
|
||||
Proposed By
|
||||
</div>
|
||||
<p className="text-sm font-bold text-text-dark">{selectedEvent.proposer?.fullName || 'Faculty'}</p>
|
||||
<p className="text-[10px] font-black text-brand-indigo uppercase">{selectedEvent.department}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
Target Audience
|
||||
</div>
|
||||
<p className="text-sm font-bold text-text-dark">{selectedEvent.academicYears.join(', ')} Batches</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sponsors Section */}
|
||||
{selectedEvent.sponsors && selectedEvent.sponsors.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
|
||||
<Heart className="w-3.5 h-3.5 text-brand-indigo" />
|
||||
Event Sponsors
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedEvent.sponsors.map((sponsor, idx) => (
|
||||
<span key={idx} className="px-3 py-1 bg-brand-glow text-brand-indigo rounded-lg text-[10px] font-black uppercase tracking-widest border border-brand-indigo/10">
|
||||
{sponsor}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 pt-4 border-t border-slate-50">
|
||||
<div className="p-4 bg-slate-50 rounded-2xl">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted mb-1">
|
||||
<Wallet className="w-3.5 h-3.5" />
|
||||
Estimated Budget
|
||||
</div>
|
||||
<p className="text-lg font-black text-text-dark">₹{selectedEvent.budget?.toLocaleString() || '0'}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-slate-50 rounded-2xl">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted mb-1">
|
||||
<Ticket className="w-3.5 h-3.5" />
|
||||
Registration
|
||||
</div>
|
||||
<p className="text-lg font-black text-text-dark">
|
||||
{selectedEvent.hasRegistrationFee ? `₹${selectedEvent.registrationFee}` : 'FREE'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEvent.conflictMessage && (
|
||||
<div className="p-4 bg-red-50 border border-red-100 rounded-2xl flex items-center gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase text-red-500">Critical Conflict Detected</p>
|
||||
<p className="text-xs font-bold text-red-700">{selectedEvent.conflictMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<button
|
||||
onClick={() => handleAction(selectedEvent.id, 'reject')}
|
||||
disabled={processingId === selectedEvent.id}
|
||||
className="flex-1 bg-red-50 text-red-600 py-4 rounded-2xl font-black text-[11px] uppercase tracking-widest hover:bg-red-100 transition-all"
|
||||
>
|
||||
Reject Proposal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(selectedEvent.id, 'approve')}
|
||||
disabled={processingId === selectedEvent.id || !!selectedEvent.conflictMessage}
|
||||
className={cn(
|
||||
"flex-1 py-4 rounded-2xl font-black text-[11px] uppercase tracking-widest transition-all premium-shadow",
|
||||
selectedEvent.conflictMessage ? "bg-slate-200 text-slate-400" : "bg-brand-navy text-white hover:scale-[1.02]"
|
||||
)}
|
||||
>
|
||||
{processingId === selectedEvent.id ? 'Processing...' : 'Approve Event'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,787 @@
|
||||
import { API_BASE_URL } from '../../lib/config';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Zap,
|
||||
ArrowRightLeft,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ShieldAlert,
|
||||
Play,
|
||||
Building2,
|
||||
Users,
|
||||
Layers,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
ChevronUp
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { cn } from '../../lib/utils';
|
||||
import {
|
||||
format,
|
||||
addMonths,
|
||||
subMonths,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
eachDayOfInterval,
|
||||
isSameMonth,
|
||||
isSameDay
|
||||
} from 'date-fns';
|
||||
|
||||
const DEPARTMENTS = [
|
||||
"AI&DS", "AI&ML", "CSE", "CCE", "CSBS", "ECE",
|
||||
"MECH", "EE(VLSI)", "BIOTECH", "Placement Department",
|
||||
"H&S Dept", "Club", "Centre"
|
||||
];
|
||||
|
||||
const BATCHES = ['1st Year', '2nd Year', '3rd Year', '4th Year'];
|
||||
|
||||
const parseDate = (dateSource: any): Date | null => {
|
||||
if (!dateSource) return null;
|
||||
if (typeof dateSource === 'string') return new Date(dateSource);
|
||||
if (Array.isArray(dateSource)) {
|
||||
return new Date(dateSource[0], dateSource[1] - 1, dateSource[2], dateSource[3] || 0, dateSource[4] || 0);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
interface Event {
|
||||
id: number;
|
||||
title: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
department: string;
|
||||
academicYears: string[];
|
||||
institution?: string;
|
||||
type?: string;
|
||||
category?: string;
|
||||
location?: string;
|
||||
proposer?: any;
|
||||
}
|
||||
|
||||
const MiniCalendar: React.FC<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
date: Date;
|
||||
onDateChange: (d: Date) => void;
|
||||
events: Event[];
|
||||
accentColor: string;
|
||||
onEventClick?: (e: Event) => void;
|
||||
}> = ({ title, subtitle, date, onDateChange, events, accentColor, onEventClick }) => {
|
||||
const days = eachDayOfInterval({
|
||||
start: startOfWeek(startOfMonth(date)),
|
||||
end: endOfWeek(endOfMonth(date))
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-[2.5rem] border border-slate-100 premium-shadow overflow-hidden flex-1">
|
||||
<div className="p-6 border-b border-slate-50 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-black text-text-dark tracking-tight">{title}</h4>
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-text-muted mt-0.5">{subtitle}</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => onDateChange(subMonths(date, 1))} className="p-1.5 hover:bg-slate-50 rounded-lg transition-all"><ChevronLeft className="w-4 h-4 text-text-muted" /></button>
|
||||
<button onClick={() => onDateChange(addMonths(date, 1))} className="p-1.5 hover:bg-slate-50 rounded-lg transition-all"><ChevronRight className="w-4 h-4 text-text-muted" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="text-center mb-4">
|
||||
<span className="text-lg font-black text-text-dark">{format(date, 'MMMM yyyy')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-px bg-slate-50 border border-slate-50 rounded-2xl overflow-hidden">
|
||||
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d, i) => (
|
||||
<div key={`${d}-${i}`} className="bg-white py-2 text-center text-[8px] font-black text-text-muted">{d}</div>
|
||||
))}
|
||||
{days.map(day => {
|
||||
const isCurrentMonth = isSameMonth(day, date);
|
||||
const dayStr = format(day, 'yyyy-MM-dd');
|
||||
const dayEvents = events.filter(e => {
|
||||
const d = parseDate(e.startDate);
|
||||
return d && format(d, 'yyyy-MM-dd') === dayStr;
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={day.toString()} className={cn(
|
||||
"min-h-[8rem] bg-white p-2 border-t border-slate-50 relative",
|
||||
!isCurrentMonth && "opacity-20"
|
||||
)}>
|
||||
<span className="text-xs font-black text-text-muted mb-2 block">{format(day, 'd')}</span>
|
||||
<div className="space-y-1.5 overflow-y-auto max-h-[6rem] custom-scrollbar">
|
||||
{dayEvents.map(e => (
|
||||
<div
|
||||
key={e.id}
|
||||
onClick={() => onEventClick?.(e)}
|
||||
className="bg-brand-glow border border-brand-indigo/10 rounded-lg p-2 transition-all hover:scale-[1.02] cursor-pointer"
|
||||
>
|
||||
<h5 className="text-[9px] font-black text-brand-indigo leading-tight truncate">{e.title}</h5>
|
||||
<p className="text-[7px] font-bold text-brand-indigo/60 uppercase tracking-wider">{e.department}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AutomationView: React.FC = () => {
|
||||
const [sourceDate, setSourceDate] = useState(new Date());
|
||||
const [targetDate, setTargetDate] = useState(addMonths(new Date(), 6));
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [status, setStatus] = useState<'idle' | 'success'>('idle');
|
||||
|
||||
const [sourceConfig, setSourceConfig] = useState({
|
||||
institution: 'RIT',
|
||||
department: 'AI&DS',
|
||||
batch: '1st Year'
|
||||
});
|
||||
|
||||
const [targetConfig, setTargetConfig] = useState({
|
||||
institution: 'RIT',
|
||||
department: 'AI&DS',
|
||||
batch: '1st Year'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, []);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/api/events');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setEvents(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch events:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSourceEvents = events.filter(e =>
|
||||
e.department?.trim() === sourceConfig.department.trim() &&
|
||||
(e.academicYears || []).map((y: string) => y.trim()).includes(sourceConfig.batch.trim())
|
||||
);
|
||||
|
||||
const filteredTargetEvents = events.filter(e =>
|
||||
e.department?.trim() === targetConfig.department.trim() &&
|
||||
(e.academicYears || []).map((y: string) => y.trim()).includes(targetConfig.batch.trim())
|
||||
);
|
||||
|
||||
const [proposedChanges, setProposedChanges] = useState<any[]>([]);
|
||||
const [showReviewModal, setShowReviewModal] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState<any>(null);
|
||||
|
||||
const prepareSwap = () => {
|
||||
const changes = filteredSourceEvents.map(event => {
|
||||
const originalDate = parseDate(event.startDate);
|
||||
if (!originalDate) return { ...event, resolution: 'skip' };
|
||||
|
||||
const isOddSem = originalDate.getMonth() >= 6;
|
||||
const targetBaseDate = isOddSem ? subMonths(originalDate, 6) : addMonths(originalDate, 6);
|
||||
|
||||
// Preserving time
|
||||
const targetDateStr = format(targetBaseDate, 'yyyy-MM-dd');
|
||||
const timeStr = format(originalDate, 'HH:mm:ss');
|
||||
const finalDateTimeStr = `${targetDateStr}T${timeStr}`;
|
||||
|
||||
const dayOfWeek = targetBaseDate.getDay();
|
||||
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||
|
||||
// Conflict Detection (Any event on target date)
|
||||
const targetConflicts = filteredTargetEvents.filter(te =>
|
||||
format(parseDate(te.startDate)!, 'yyyy-MM-dd') === targetDateStr
|
||||
);
|
||||
|
||||
const hasTimeConflict = targetConflicts.some(tc => {
|
||||
const tcDate = parseDate(tc.startDate);
|
||||
return tcDate && format(tcDate, 'HH:mm') === format(originalDate, 'HH:mm');
|
||||
});
|
||||
|
||||
const isAlreadySwapped = targetConflicts.some(tc =>
|
||||
tc.title.trim().toLowerCase() === event.title.trim().toLowerCase()
|
||||
);
|
||||
|
||||
return {
|
||||
...event,
|
||||
originalDate: event.startDate,
|
||||
targetDate: finalDateTimeStr, // Now includes time
|
||||
targetDepartment: targetConfig.department,
|
||||
targetBatch: targetConfig.batch,
|
||||
isWeekend,
|
||||
isAlreadySwapped,
|
||||
targetConflicts,
|
||||
hasTimeConflict,
|
||||
resolution: isAlreadySwapped ? 'skip' : (targetConflicts.length > 0 ? 'review' : 'create'),
|
||||
adjustment: isWeekend ? 'monday' : 'none'
|
||||
};
|
||||
});
|
||||
setProposedChanges(changes);
|
||||
setShowReviewModal(true);
|
||||
};
|
||||
|
||||
const handleExecuteSwap = async () => {
|
||||
setIsExecuting(true);
|
||||
|
||||
const finalizedChanges = proposedChanges
|
||||
.filter(c => c.resolution !== 'skip')
|
||||
.map(change => {
|
||||
let finalDate = new Date(change.targetDate);
|
||||
|
||||
// Handle Weekend Adjustments
|
||||
if (change.resolution !== 'reschedule' && change.isWeekend) {
|
||||
if (change.adjustment === 'friday') {
|
||||
const day = finalDate.getDay();
|
||||
const diff = day === 6 ? 1 : 2;
|
||||
finalDate.setDate(finalDate.getDate() - diff);
|
||||
} else if (change.adjustment === 'monday') {
|
||||
const day = finalDate.getDay();
|
||||
const diff = day === 6 ? 2 : 1;
|
||||
finalDate.setDate(finalDate.getDate() + diff);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...change,
|
||||
finalDate: format(finalDate, "yyyy-MM-dd'T'HH:mm:ss")
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Finalized Swap Payload:', finalizedChanges);
|
||||
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/api/events/batch-create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(finalizedChanges.map(c => ({
|
||||
...c,
|
||||
targetDepartment: c.targetDepartment,
|
||||
targetBatch: c.targetBatch,
|
||||
startDate: c.finalDate
|
||||
})))
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setStatus('success');
|
||||
fetchEvents();
|
||||
} else {
|
||||
const errData = await response.json();
|
||||
alert('Failed to execute automation swap: ' + (errData.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Automation swap failed:', error);
|
||||
alert('Network error during automation swap');
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
setShowReviewModal(false);
|
||||
setTimeout(() => setStatus('idle'), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-20">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-text-dark tracking-tight">Event Automation</h2>
|
||||
<p className="text-text-muted font-medium">Configure and manage automated department event workflows.</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-amber-50 rounded-2xl flex items-center justify-center text-amber-500 premium-shadow">
|
||||
<Zap className="w-6 h-6 fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="bg-white rounded-[2.5rem] p-8 border border-slate-100 premium-shadow relative overflow-hidden">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-10 h-10 bg-slate-50 rounded-xl flex items-center justify-center text-text-muted">
|
||||
<Building2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-black text-text-dark">Source Selection</h3>
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-text-muted">Select branch to clone events from</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-[8px] font-black uppercase tracking-widest text-text-muted mb-2 block">Institution</label>
|
||||
<div className="bg-slate-50 rounded-xl p-3 flex justify-between items-center cursor-pointer">
|
||||
<span className="text-xs font-bold text-text-dark">RIT</span>
|
||||
<ChevronDown className="w-3 h-3 text-text-muted" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[8px] font-black uppercase tracking-widest text-text-muted mb-2 block">Department</label>
|
||||
<select
|
||||
value={sourceConfig.department}
|
||||
onChange={(e) => setSourceConfig({...sourceConfig, department: e.target.value})}
|
||||
className="w-full bg-slate-50 border-none rounded-xl p-3 text-xs font-bold text-text-dark outline-none appearance-none"
|
||||
>
|
||||
{DEPARTMENTS.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[8px] font-black uppercase tracking-widest text-text-muted mb-2 block">Batch</label>
|
||||
<select
|
||||
value={sourceConfig.batch}
|
||||
onChange={(e) => setSourceConfig({...sourceConfig, batch: e.target.value})}
|
||||
className="w-full bg-slate-50 border-none rounded-xl p-3 text-xs font-bold text-text-dark outline-none appearance-none"
|
||||
>
|
||||
{BATCHES.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-[2.5rem] p-8 border border-slate-100 premium-shadow relative overflow-hidden">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-10 h-10 bg-brand-indigo rounded-xl flex items-center justify-center text-white">
|
||||
<ArrowRightLeft className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-black text-text-dark">Target Workspace</h3>
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-brand-indigo">Automation output destination</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-[8px] font-black uppercase tracking-widest text-text-muted mb-2 block">Target Inst</label>
|
||||
<div className="bg-slate-50 rounded-xl p-3 flex justify-between items-center cursor-pointer">
|
||||
<span className="text-xs font-bold text-text-dark">RIT</span>
|
||||
<ChevronDown className="w-3 h-3 text-text-muted" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[8px] font-black uppercase tracking-widest text-text-muted mb-2 block">Target Dept</label>
|
||||
<select
|
||||
value={targetConfig.department}
|
||||
onChange={(e) => setTargetConfig({...targetConfig, department: e.target.value})}
|
||||
className="w-full bg-slate-50 border-none rounded-xl p-3 text-xs font-bold text-text-dark outline-none appearance-none"
|
||||
>
|
||||
{DEPARTMENTS.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[8px] font-black uppercase tracking-widest text-text-muted mb-2 block">Target Batch</label>
|
||||
<select
|
||||
value={targetConfig.batch}
|
||||
onChange={(e) => setTargetConfig({...targetConfig, batch: e.target.value})}
|
||||
className="w-full bg-slate-50 border-none rounded-xl p-3 text-xs font-bold text-text-dark outline-none appearance-none"
|
||||
>
|
||||
{BATCHES.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-brand-navy rounded-3xl p-6 flex items-center justify-between text-white premium-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center text-amber-400">
|
||||
<ShieldAlert className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-black tracking-tight">Automation Safety Protocols Active</h4>
|
||||
<p className="text-[9px] font-medium text-white/50">Cloning institutional events shifts schedules by +/- 6 months (Semester Swap).</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<button className="text-[9px] font-black uppercase tracking-widest text-white/40 hover:text-white transition-colors">Clear Workspace</button>
|
||||
<button
|
||||
onClick={prepareSwap}
|
||||
disabled={isExecuting || filteredSourceEvents.length === 0}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-8 py-4 rounded-2xl font-black text-[10px] uppercase tracking-[0.2em] transition-all premium-shadow",
|
||||
status === 'success' ? "bg-emerald-500 text-white" :
|
||||
filteredSourceEvents.length === 0 ? "bg-slate-200 text-slate-400 cursor-not-allowed" :
|
||||
"bg-brand-indigo text-white hover:scale-105"
|
||||
)}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : status === 'success' ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Swap Completed
|
||||
</>
|
||||
) : filteredSourceEvents.length === 0 ? (
|
||||
<>
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
No Events Found to Swap
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightLeft className="w-4 h-4" />
|
||||
Execute Semester Swap
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<MiniCalendar
|
||||
title="Primary Schedule (Source)"
|
||||
subtitle={`${sourceConfig.department} • ${sourceConfig.batch}`}
|
||||
date={sourceDate}
|
||||
onDateChange={setSourceDate}
|
||||
events={filteredSourceEvents}
|
||||
accentColor="bg-brand-indigo"
|
||||
onEventClick={setSelectedEvent}
|
||||
/>
|
||||
<MiniCalendar
|
||||
title="Target Workspace Events"
|
||||
subtitle={`${targetConfig.department} • ${targetConfig.batch}`}
|
||||
date={targetDate}
|
||||
onDateChange={setTargetDate}
|
||||
events={filteredTargetEvents}
|
||||
accentColor="bg-emerald-400"
|
||||
onEventClick={setSelectedEvent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{selectedEvent && (
|
||||
<div className="fixed inset-0 z-[150] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setSelectedEvent(null)}
|
||||
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="relative w-full max-w-xl bg-white rounded-[2.5rem] premium-shadow overflow-hidden"
|
||||
>
|
||||
<div className="p-8 bg-brand-navy text-white flex justify-between items-start">
|
||||
<div>
|
||||
<span className="px-3 py-1 bg-white/10 rounded-full text-[8px] font-black uppercase tracking-widest text-brand-indigo border border-white/10">
|
||||
{selectedEvent.category || 'Academic'} Event
|
||||
</span>
|
||||
<h3 className="text-2xl font-black tracking-tight mt-3">{selectedEvent.title}</h3>
|
||||
</div>
|
||||
<button onClick={() => setSelectedEvent(null)} className="p-2 hover:bg-white/10 rounded-xl transition-all">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[8px] font-black uppercase tracking-widest text-text-muted">Start Date</span>
|
||||
<p className="text-xs font-bold text-text-dark">
|
||||
{parseDate(selectedEvent.startDate) ? format(parseDate(selectedEvent.startDate)!, 'PPP') : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[8px] font-black uppercase tracking-widest text-text-muted">Venue</span>
|
||||
<p className="text-xs font-bold text-text-dark">{selectedEvent.location || 'Seminar Hall'}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[8px] font-black uppercase tracking-widest text-text-muted">Department</span>
|
||||
<p className="text-xs font-bold text-text-dark">{selectedEvent.department}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[8px] font-black uppercase tracking-widest text-text-muted">Academic Year</span>
|
||||
<p className="text-xs font-bold text-text-dark">{selectedEvent.academicYears?.join(', ') || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100">
|
||||
<span className="text-[8px] font-black uppercase tracking-widest text-text-muted block mb-2">Proposer Information</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-white rounded-lg border border-slate-200 flex items-center justify-center text-[10px] font-black text-brand-indigo">
|
||||
{selectedEvent.proposer?.fullName?.charAt(0) || 'F'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-text-dark leading-none">{selectedEvent.proposer?.fullName || 'Faculty Member'}</p>
|
||||
<p className="text-[8px] font-bold text-text-muted mt-1">{selectedEvent.proposer?.email || 'faculty@rit.edu'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 border-t border-slate-100 flex justify-end">
|
||||
<button
|
||||
onClick={() => setSelectedEvent(null)}
|
||||
className="px-10 py-4 bg-brand-navy text-white rounded-2xl font-black text-xs uppercase tracking-widest premium-shadow hover:scale-105 active:scale-95 transition-all"
|
||||
>
|
||||
Close Insights
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{showReviewModal && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setShowReviewModal(false)}
|
||||
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="relative w-full max-w-4xl bg-white rounded-[2.5rem] premium-shadow overflow-hidden flex flex-col max-h-[80vh]"
|
||||
>
|
||||
<div className="p-8 bg-brand-navy text-white flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black tracking-tight">Review Automated Changes</h3>
|
||||
<p className="text-white/60 text-xs font-medium mt-1">Audit and adjust planned semester-swap dates.</p>
|
||||
</div>
|
||||
<button onClick={() => setShowReviewModal(false)} className="p-2 hover:bg-white/10 rounded-xl transition-all">
|
||||
<ChevronDown className="w-6 h-6 rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
|
||||
<div className="space-y-4">
|
||||
{proposedChanges.map((change, idx) => (
|
||||
<div key={change.id} className={cn(
|
||||
"p-6 rounded-[2.5rem] border transition-all space-y-6",
|
||||
change.resolution === 'skip' ? "bg-slate-50 border-slate-100 opacity-60" :
|
||||
change.targetConflicts?.length > 0 ? "bg-red-50/30 border-red-100" :
|
||||
change.isWeekend ? "bg-amber-50 border-amber-100" : "bg-white border-slate-100"
|
||||
)}>
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[8px] font-black uppercase tracking-widest text-text-muted">Event</span>
|
||||
<h4 className="text-sm font-black text-text-dark">{change.title}</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-slate-100 rounded-lg">
|
||||
<Calendar className="w-3 h-3 text-text-muted" />
|
||||
<span className="text-[10px] font-bold text-text-dark">
|
||||
{parseDate(change.originalDate) ? format(parseDate(change.originalDate)!, 'MMM d') : 'N/A'}
|
||||
</span>
|
||||
<ArrowRightLeft className="w-3 h-3 text-brand-indigo" />
|
||||
<span className="text-[10px] font-black text-brand-indigo">
|
||||
{parseDate(change.targetDate) ? format(parseDate(change.targetDate)!, 'MMM d, yyyy') : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{change.isAlreadySwapped && (
|
||||
<span className="flex items-center gap-1.5 px-3 py-1 bg-emerald-100 text-emerald-700 rounded-full text-[8px] font-black uppercase tracking-widest">
|
||||
<CheckCircle2 className="w-3 h-3" /> Already Swapped
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{change.targetConflicts?.length > 0 && change.resolution !== 'skip' && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-red-100 text-red-700 rounded-2xl border border-red-200">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-black uppercase">Conflict Detected</span>
|
||||
<span className="text-[8px] font-bold opacity-70">
|
||||
{change.targetConflicts.length} event(s) already on this date
|
||||
{change.hasTimeConflict && " (Time Overlap!)"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex bg-white rounded-2xl border border-slate-100 p-1.5 premium-shadow-sm">
|
||||
<button
|
||||
onClick={() => {
|
||||
const updated = [...proposedChanges];
|
||||
updated[idx].resolution = 'create';
|
||||
setProposedChanges(updated);
|
||||
}}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest transition-all",
|
||||
change.resolution === 'create' || change.resolution === 'review' ? "bg-brand-indigo text-white shadow-lg shadow-brand-indigo/20" : "text-text-muted hover:text-text-dark"
|
||||
)}
|
||||
>
|
||||
Commit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const updated = [...proposedChanges];
|
||||
updated[idx].resolution = 'reschedule';
|
||||
setProposedChanges(updated);
|
||||
}}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest transition-all",
|
||||
change.resolution === 'reschedule' ? "bg-amber-500 text-white shadow-lg shadow-amber-500/20" : "text-text-muted hover:text-text-dark"
|
||||
)}
|
||||
>
|
||||
Reschedule
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const updated = [...proposedChanges];
|
||||
updated[idx].resolution = 'skip';
|
||||
setProposedChanges(updated);
|
||||
}}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest transition-all",
|
||||
change.resolution === 'skip' ? "bg-slate-200 text-slate-600" : "text-text-muted hover:text-text-dark"
|
||||
)}
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekend Quick-Shift */}
|
||||
{change.isWeekend && change.resolution !== 'skip' && change.resolution !== 'reschedule' && (
|
||||
<div className="flex bg-amber-100/50 p-1 rounded-xl border border-amber-200">
|
||||
<button
|
||||
onClick={() => {
|
||||
const updated = [...proposedChanges];
|
||||
updated[idx].adjustment = 'friday';
|
||||
setProposedChanges(updated);
|
||||
}}
|
||||
title="Shift to Friday"
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-lg text-[8px] font-black uppercase tracking-widest transition-all",
|
||||
change.adjustment === 'friday' ? "bg-amber-600 text-white shadow-sm" : "text-amber-700 hover:bg-amber-100"
|
||||
)}
|
||||
>
|
||||
Fri
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const updated = [...proposedChanges];
|
||||
updated[idx].adjustment = 'monday';
|
||||
setProposedChanges(updated);
|
||||
}}
|
||||
title="Shift to Monday"
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-lg text-[8px] font-black uppercase tracking-widest transition-all",
|
||||
change.adjustment === 'monday' ? "bg-amber-600 text-white shadow-sm" : "text-amber-700 hover:bg-amber-100"
|
||||
)}
|
||||
>
|
||||
Mon
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Conflict / Reschedule Details */}
|
||||
{(change.resolution === 'reschedule' || (change.targetConflicts?.length > 0 && change.resolution !== 'skip')) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className="pt-6 border-t border-slate-100 space-y-4"
|
||||
>
|
||||
{change.targetConflicts?.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-[8px] font-black uppercase tracking-widest text-red-500">Conflicts at Destination</span>
|
||||
{change.targetConflicts.map((conf: any) => (
|
||||
<div key={conf.id} className="flex items-center justify-between p-3 bg-red-50/50 rounded-xl border border-red-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 bg-white rounded-lg flex items-center justify-center text-[8px] font-black text-red-500 border border-red-100">
|
||||
{conf.department.substring(0, 2)}
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-text-dark">{conf.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[9px] font-black text-red-600/60 uppercase">
|
||||
<Clock className="w-3 h-3" />
|
||||
{format(new Date(conf.startDate), 'hh:mm a')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{change.resolution === 'reschedule' && (
|
||||
<div className="flex items-end gap-4 bg-amber-50/50 p-6 rounded-[2rem] border border-amber-100">
|
||||
<div className="flex-1">
|
||||
<label className="text-[8px] font-black uppercase tracking-widest text-amber-600 mb-2 block">New Target Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={change.targetDate}
|
||||
onChange={(e) => {
|
||||
const updated = [...proposedChanges];
|
||||
updated[idx].targetDate = e.target.value;
|
||||
setProposedChanges(updated);
|
||||
}}
|
||||
className="w-full bg-white border border-amber-200 rounded-xl p-3 text-xs font-bold text-text-dark focus:outline-none focus:ring-2 focus:ring-amber-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const d = new Date(change.targetDate);
|
||||
d.setDate(d.getDate() + 1);
|
||||
const updated = [...proposedChanges];
|
||||
updated[idx].targetDate = format(d, 'yyyy-MM-dd');
|
||||
setProposedChanges(updated);
|
||||
}}
|
||||
className="p-2 bg-white border border-amber-200 rounded-lg hover:bg-amber-100 transition-all"
|
||||
>
|
||||
<ChevronUp className="w-4 h-4 text-amber-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const d = new Date(change.targetDate);
|
||||
d.setDate(d.getDate() - 1);
|
||||
const updated = [...proposedChanges];
|
||||
updated[idx].targetDate = format(d, 'yyyy-MM-dd');
|
||||
setProposedChanges(updated);
|
||||
}}
|
||||
className="p-2 bg-white border border-amber-200 rounded-lg hover:bg-amber-100 transition-all"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 text-amber-600" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-4 py-2 bg-white rounded-2xl border border-amber-100">
|
||||
<span className="text-[7px] font-black uppercase text-amber-400">Shift Result</span>
|
||||
<span className="text-[10px] font-black text-amber-700">{format(new Date(change.targetDate), 'EEEE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 border-t border-slate-100 bg-slate-50/50 flex justify-end gap-4">
|
||||
<button
|
||||
onClick={() => setShowReviewModal(false)}
|
||||
className="px-8 py-4 text-xs font-black uppercase tracking-widest text-text-muted hover:text-text-dark transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecuteSwap}
|
||||
className="px-10 py-4 bg-brand-navy text-white rounded-2xl font-black text-xs uppercase tracking-widest premium-shadow hover:scale-105 active:scale-95 transition-all"
|
||||
>
|
||||
Commit Automation Swap
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,435 @@
|
||||
import { API_BASE_URL } from '../../lib/config';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
Building2,
|
||||
GraduationCap,
|
||||
ChevronRight,
|
||||
X,
|
||||
CheckCircle2
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Pagination } from './Pagination';
|
||||
|
||||
interface ClassMapping {
|
||||
id: number;
|
||||
institution: string;
|
||||
department: string;
|
||||
academicYear: string;
|
||||
sections: string[];
|
||||
status: string;
|
||||
}
|
||||
|
||||
const ritDepartments = ['AI&DS', 'AI&ML', 'CSE', 'CCE', 'CSBS', 'ECE', 'MECH', 'EE(VLSI)', 'BIOTECH', 'Placement Department', 'H&S Dept', 'Club', 'Centre'];
|
||||
const rsbDepartments = ['PGDM'];
|
||||
const years = ['1st Year', '2nd Year', '3rd Year', '4th Year'];
|
||||
|
||||
import rsbLogo from '../../assets/images/rsb_logo.png';
|
||||
import ritLogo from '../../assets/images/college-logo.png';
|
||||
|
||||
export const ClassManagement: React.FC = () => {
|
||||
const [classes, setClasses] = useState<ClassMapping[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [newSection, setNewSection] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
institution: 'RIT',
|
||||
department: 'AI&DS',
|
||||
academicYear: '1st Year',
|
||||
sections: [] as string[]
|
||||
});
|
||||
|
||||
const currentDepartments = formData.institution === 'RIT' ? ritDepartments : rsbDepartments;
|
||||
|
||||
useEffect(() => {
|
||||
if (formData.institution === 'RSB' && formData.department !== 'PGDM') {
|
||||
setFormData(prev => ({ ...prev, department: 'PGDM', sections: [] }));
|
||||
}
|
||||
}, [formData.institution]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClasses();
|
||||
}, []);
|
||||
|
||||
const fetchClasses = async () => {
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/api/classes');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setClasses(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch classes:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSection = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newSection && !formData.sections.includes(newSection.toUpperCase())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
sections: [...formData.sections, newSection.toUpperCase()]
|
||||
});
|
||||
setNewSection('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeSection = (section: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
sections: formData.sections.filter(s => s !== section)
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (formData.sections.length === 0) {
|
||||
alert('Please add at least one section');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/api/classes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchClasses();
|
||||
setFormData({ ...formData, sections: [] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save class mapping:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (window.confirm('Are you sure you want to delete this mapping?')) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/classes/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
setClasses(prev => prev.filter(c => c.id !== id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromote = async () => {
|
||||
const confirmed = window.confirm(
|
||||
`⚠️ WARNING: ${formData.institution} ACADEMIC YEAR PROMOTION\n\n` +
|
||||
`This action will shift all academic structures for ${formData.institution} forward:\n` +
|
||||
'• 1st Year → 2nd Year\n' +
|
||||
'• 2nd Year → 3rd Year\n' +
|
||||
'• 3rd Year → 4th Year\n' +
|
||||
'• 4th Year mappings will be PERMANENTLY DELETED.\n\n' +
|
||||
'Do you wish to proceed with this irreversible operation?'
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/classes/promote?institution=${formData.institution}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (response.ok) {
|
||||
fetchClasses();
|
||||
alert(`${formData.institution} academic year promotion completed successfully!`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Promotion failed:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredClasses = classes
|
||||
.filter(c =>
|
||||
c.institution === formData.institution && (
|
||||
c.department.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.academicYear.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
)
|
||||
.sort((a, b) => b.id - a.id);
|
||||
|
||||
const currentClasses = filteredClasses.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-black text-text-dark tracking-tight mb-1">Class Management</h1>
|
||||
<p className="text-text-muted font-black uppercase tracking-widest text-[10px]">Define institutional academic structures & sections</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handlePromote}
|
||||
className="flex items-center gap-3 bg-[#FFF9ED] text-[#D97706] border border-[#FDE68A] px-6 py-3 rounded-2xl font-black text-[11px] uppercase tracking-widest hover:scale-105 transition-all"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Promote Academic Year
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="w-12 h-12 bg-brand-glow rounded-2xl flex items-center justify-center text-brand-indigo premium-shadow">
|
||||
<Building2 className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
||||
{/* Left Column - Form */}
|
||||
<div className="lg:col-span-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="bg-white rounded-[2.5rem] border border-slate-100 premium-shadow p-8 space-y-8 sticky top-10"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-brand-indigo rounded-xl flex items-center justify-center text-white">
|
||||
<Plus className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-xl font-black text-text-dark tracking-tight">Add New Mapping</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Institution */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-3 block">Select Institution</label>
|
||||
<div className="flex gap-4">
|
||||
{[
|
||||
{ id: 'RIT', logo: ritLogo, label: 'RIT' },
|
||||
{ id: 'RSB', logo: rsbLogo, label: 'RSB' }
|
||||
].map(inst => (
|
||||
<button
|
||||
key={inst.id}
|
||||
type="button"
|
||||
onClick={() => setFormData({...formData, institution: inst.id})}
|
||||
className={cn(
|
||||
"flex-1 h-20 rounded-2xl transition-all border-2 flex items-center justify-center relative overflow-hidden group",
|
||||
formData.institution === inst.id
|
||||
? "bg-white border-brand-indigo premium-shadow"
|
||||
: "bg-slate-50 border-transparent grayscale opacity-50 hover:opacity-100 hover:grayscale-0 hover:bg-white hover:border-slate-200"
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={inst.logo}
|
||||
alt={inst.label}
|
||||
className={cn(
|
||||
"transition-all",
|
||||
inst.id === 'RIT' ? "h-10" : "h-7"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Department */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-3 block">Department</label>
|
||||
<select
|
||||
value={formData.department}
|
||||
onChange={e => setFormData({...formData, department: e.target.value})}
|
||||
className="w-full bg-slate-50 border-transparent rounded-2xl py-4 px-6 text-sm font-bold focus:bg-white focus:border-brand-indigo transition-all appearance-none"
|
||||
>
|
||||
{currentDepartments.map(dept => <option key={dept} value={dept}>{dept}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Academic Year */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-3 block">Academic Year</label>
|
||||
<select
|
||||
value={formData.academicYear}
|
||||
onChange={e => setFormData({...formData, academicYear: e.target.value})}
|
||||
className="w-full bg-slate-50 border-transparent rounded-2xl py-4 px-6 text-sm font-bold focus:bg-white focus:border-brand-indigo transition-all appearance-none"
|
||||
>
|
||||
{years.map(year => <option key={year} value={year}>{year}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-3 block">Sections</label>
|
||||
<div className="bg-slate-50 rounded-2xl p-4 space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AnimatePresence>
|
||||
{formData.sections.map(section => (
|
||||
<motion.span
|
||||
key={section}
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
className="px-3 py-1.5 bg-white rounded-lg text-xs font-black text-brand-indigo border border-brand-indigo/10 flex items-center gap-2 group"
|
||||
>
|
||||
{section}
|
||||
<button onClick={() => removeSection(section)} className="hover:text-status-danger transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</motion.span>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<form onSubmit={handleAddSection} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type section (eg. A)..."
|
||||
value={newSection}
|
||||
onChange={e => setNewSection(e.target.value)}
|
||||
className="flex-1 bg-transparent border-none text-xs font-bold focus:ring-0 p-0"
|
||||
/>
|
||||
<button type="submit" className="w-8 h-8 bg-brand-glow rounded-lg flex items-center justify-center text-brand-indigo hover:bg-brand-indigo hover:text-white transition-all">
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="w-full bg-brand-indigo text-white rounded-2xl py-5 font-black text-[12px] uppercase tracking-[0.2em] premium-shadow hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-3"
|
||||
>
|
||||
<Layers className="w-5 h-5" />
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Table */}
|
||||
<div className="lg:col-span-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="bg-white rounded-[2.5rem] border border-slate-100 premium-shadow overflow-hidden"
|
||||
>
|
||||
<div className="p-8 border-b border-slate-50 flex items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-2 h-8 bg-brand-indigo rounded-full" />
|
||||
<h3 className="text-xl font-black text-text-dark tracking-tight">Academic Structure</h3>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search structures..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-slate-50 border-transparent rounded-2xl py-3 pl-12 pr-4 text-sm font-bold focus:bg-white focus:border-brand-indigo transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50/50">
|
||||
<th className="px-8 py-5 text-[9px] font-black uppercase tracking-[0.2em] text-text-muted">Institution</th>
|
||||
<th className="px-8 py-5 text-[9px] font-black uppercase tracking-[0.2em] text-text-muted">Department \ Year</th>
|
||||
<th className="px-8 py-5 text-[9px] font-black uppercase tracking-[0.2em] text-text-muted">Configured Sections</th>
|
||||
<th className="px-8 py-5 text-[9px] font-black uppercase tracking-[0.2em] text-text-muted text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={4} className="p-20 text-center text-text-muted italic">Loading structure...</td></tr>
|
||||
) : filteredClasses.length === 0 ? (
|
||||
<tr><td colSpan={4} className="p-20 text-center text-text-muted italic">No academic structures defined.</td></tr>
|
||||
) : (
|
||||
currentClasses.map((item, idx) => (
|
||||
<motion.tr
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="group hover:bg-slate-50/30 transition-colors"
|
||||
>
|
||||
<td className="px-8 py-6">
|
||||
<div className="w-16 h-10 bg-slate-50 rounded-xl border border-slate-100 flex items-center justify-center p-1.5">
|
||||
<img
|
||||
src={item.institution === 'RIT' ? ritLogo : rsbLogo}
|
||||
alt={item.institution}
|
||||
className="h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="w-12 h-12 rounded-2xl bg-slate-50 border border-slate-100 flex flex-col items-center justify-center">
|
||||
<span className="text-[9px] font-black text-text-muted uppercase">{item.department.substring(0, 3)}</span>
|
||||
<div className="w-4 h-0.5 bg-brand-indigo/30 rounded-full mt-0.5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-black text-text-dark">{item.department}</h4>
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-brand-indigo mt-0.5">
|
||||
<GraduationCap className="w-3.5 h-3.5" />
|
||||
{item.academicYear}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.sections.map(section => (
|
||||
<span key={section} className="w-7 h-7 rounded-lg bg-white border border-slate-200 flex items-center justify-center text-[10px] font-black text-text-muted group-hover:border-brand-indigo group-hover:text-brand-indigo transition-all">
|
||||
{section}
|
||||
</span>
|
||||
))}
|
||||
<div className="ml-4 flex items-center gap-1.5 px-3 py-1 bg-emerald-50 text-status-success rounded-lg border border-emerald-100/50">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6 text-right">
|
||||
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-all">
|
||||
<button className="p-2.5 bg-white border border-slate-200 rounded-xl text-slate-400 hover:text-brand-indigo hover:border-brand-indigo premium-shadow-sm transition-all">
|
||||
<Layers className="w-4.5 h-4.5" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(item.id)} className="p-2.5 bg-white border border-slate-200 rounded-xl text-slate-400 hover:text-status-danger hover:border-status-danger premium-shadow-sm transition-all">
|
||||
<Trash2 className="w-4.5 h-4.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalItems={filteredClasses.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={(val) => {
|
||||
setItemsPerPage(val);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
itemsPerPageOptions={[5, 10, 20, 50]}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Calendar, CheckCircle2, Circle } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { INSTITUTIONAL_EVENTS } from '../../constants/institutionalEvents';
|
||||
import { getCurrentAcademicYear } from '../../lib/dateUtils';
|
||||
import { API_BASE_URL } from '../../lib/config';
|
||||
|
||||
interface ClubEvent {
|
||||
id: string;
|
||||
name: string;
|
||||
month: string;
|
||||
semester: 'ODD' | 'EVEN';
|
||||
}
|
||||
|
||||
interface Event {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
academicYears: string[];
|
||||
}
|
||||
|
||||
interface ClubInstitutionalChecklistProps {
|
||||
onEventClick: (data: { eventName: string, isClubEvent: boolean }) => void;
|
||||
}
|
||||
|
||||
export const ClubInstitutionalChecklist: React.FC<ClubInstitutionalChecklistProps> = ({ onEventClick }) => {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const currentAcademicYear = getCurrentAcademicYear();
|
||||
|
||||
useEffect(() => {
|
||||
fetch(API_BASE_URL + '/api/events')
|
||||
.then(res => res.json())
|
||||
.then(data => setEvents(data))
|
||||
.catch(err => console.error('Failed to fetch events:', err));
|
||||
}, []);
|
||||
|
||||
const getEventStatus = (eventName: string) => {
|
||||
const matching = events.filter(e =>
|
||||
e.title.toLowerCase() === eventName.toLowerCase() &&
|
||||
e.academicYears?.includes(currentAcademicYear) &&
|
||||
(e.status?.toUpperCase() === 'APPROVED' || e.status?.toUpperCase() === 'COMPLETED')
|
||||
);
|
||||
return matching.length > 0;
|
||||
};
|
||||
|
||||
const oddSemesterEvents = INSTITUTIONAL_EVENTS.filter(e => e.semester === 'ODD');
|
||||
const evenSemesterEvents = INSTITUTIONAL_EVENTS.filter(e => e.semester === 'EVEN');
|
||||
|
||||
const SemesterCard = ({ title, subtitle, events, gradient }: { title: string, subtitle: string, events: ClubEvent[], gradient: string }) => (
|
||||
<div className="flex-1 min-w-[400px] bg-white rounded-[2.5rem] border border-slate-100 premium-shadow overflow-hidden">
|
||||
<div className={cn("p-8 text-white flex justify-between items-start", gradient)}>
|
||||
<div>
|
||||
<h3 className="text-2xl font-black tracking-tight">{title}</h3>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest opacity-80 mt-1">{subtitle}</p>
|
||||
</div>
|
||||
<Calendar className="w-6 h-6 opacity-60" />
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6 max-h-[600px] overflow-y-auto custom-scrollbar">
|
||||
{events.map((event, i) => {
|
||||
const isScheduled = getEventStatus(event.name);
|
||||
return (
|
||||
<motion.button
|
||||
key={event.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
onClick={() => onEventClick({ eventName: event.name, isClubEvent: true })}
|
||||
className="w-full flex items-center gap-6 group text-left"
|
||||
>
|
||||
<div className="relative">
|
||||
<Circle className={cn("w-8 h-8 fill-white", isScheduled ? "text-emerald-500" : "text-slate-100")} />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{isScheduled ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-slate-200 group-hover:border-brand-indigo transition-colors" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className={cn("text-sm font-black transition-colors", isScheduled ? "text-emerald-600" : "text-text-dark group-hover:text-brand-indigo")}>{event.name}</p>
|
||||
<p className="text-[9px] font-bold text-text-muted uppercase tracking-widest mt-0.5">
|
||||
{isScheduled ? 'Scheduled & Approved' : `Target: ${event.month}`}
|
||||
</p>
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-1.5 h-10 bg-brand-indigo rounded-full" />
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-text-dark tracking-tighter">Institutional Checklists</h2>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-text-muted mt-1">Track Institutional Club Requirements</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col xl:flex-row gap-8">
|
||||
<SemesterCard
|
||||
title="ODD Semester"
|
||||
subtitle="Club & Institutional Events"
|
||||
events={oddSemesterEvents}
|
||||
gradient="bg-gradient-to-r from-blue-600 to-indigo-600"
|
||||
/>
|
||||
<SemesterCard
|
||||
title="Even Semester"
|
||||
subtitle="Club & Institutional Events"
|
||||
events={evenSemesterEvents}
|
||||
gradient="bg-gradient-to-r from-indigo-600 to-purple-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X,
|
||||
Calendar as CalendarIcon,
|
||||
MapPin,
|
||||
Clock,
|
||||
Building2,
|
||||
Users,
|
||||
Tag,
|
||||
Target
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface EventDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
event: any;
|
||||
}
|
||||
|
||||
export const EventDetailsModal: React.FC<EventDetailsModalProps> = ({ isOpen, onClose, event }) => {
|
||||
if (!isOpen || !event) return null;
|
||||
|
||||
const parseDate = (dateSource: any): Date | null => {
|
||||
if (!dateSource) return null;
|
||||
if (typeof dateSource === 'string') return new Date(dateSource);
|
||||
if (Array.isArray(dateSource)) {
|
||||
return new Date(dateSource[0], dateSource[1] - 1, dateSource[2], dateSource[3] || 0, dateSource[4] || 0);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const startDate = parseDate(event.startDate);
|
||||
const endDate = parseDate(event.endDate);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="relative w-full max-w-2xl bg-white rounded-[2.5rem] shadow-2xl overflow-hidden flex flex-col max-h-[90vh]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-8 bg-brand-navy text-white flex justify-between items-start relative overflow-hidden">
|
||||
<div className="relative z-10 pr-12">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="px-3 py-1 bg-white/10 rounded-full text-[10px] font-black uppercase tracking-widest border border-white/10">
|
||||
{event.department}
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-brand-indigo/30 rounded-full text-[10px] font-black uppercase tracking-widest text-brand-glow border border-brand-indigo/50">
|
||||
{event.type}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-black tracking-tight leading-tight">
|
||||
{event.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-8 right-8 p-2 bg-white/10 hover:bg-white/20 rounded-xl transition-colors z-10"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Background Decoration */}
|
||||
<div className="absolute -right-10 -bottom-10 w-40 h-40 bg-brand-indigo/20 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8 overflow-y-auto custom-scrollbar space-y-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="flex items-start gap-4 p-4 rounded-2xl bg-slate-50 border border-slate-100">
|
||||
<div className="w-10 h-10 bg-white rounded-xl flex items-center justify-center shadow-sm text-brand-indigo shrink-0">
|
||||
<CalendarIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-1">Date & Time</p>
|
||||
<p className="text-sm font-bold text-text-dark">
|
||||
{startDate ? format(startDate, 'MMMM d, yyyy') : 'TBD'}
|
||||
</p>
|
||||
<p className="text-xs font-bold text-text-muted mt-0.5">
|
||||
{startDate ? format(startDate, 'hh:mm a') : 'TBD'} - {endDate ? format(endDate, 'hh:mm a') : 'TBD'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 p-4 rounded-2xl bg-slate-50 border border-slate-100">
|
||||
<div className="w-10 h-10 bg-white rounded-xl flex items-center justify-center shadow-sm text-emerald-500 shrink-0">
|
||||
<MapPin className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-1">Venue</p>
|
||||
<p className="text-sm font-bold text-text-dark">
|
||||
{event.location || 'TBD'}
|
||||
</p>
|
||||
<p className="text-xs font-bold text-text-muted mt-0.5">
|
||||
{event.institution || 'RIT'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.description && (
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-text-dark mb-3">Event Description</h3>
|
||||
<div className="p-5 rounded-2xl bg-slate-50 border border-slate-100">
|
||||
<p className="text-sm font-medium text-text-muted leading-relaxed whitespace-pre-wrap">
|
||||
{event.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{event.academicYears && event.academicYears.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-text-dark mb-3 flex items-center gap-2">
|
||||
<Target className="w-4 h-4 text-brand-indigo" />
|
||||
Target Audience
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{event.academicYears.map((year: string) => (
|
||||
<span key={year} className="px-3 py-1.5 bg-brand-glow text-brand-indigo text-xs font-bold rounded-xl border border-brand-indigo/10">
|
||||
{year}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.targetedSections && event.targetedSections.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-text-dark mb-3 flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-emerald-500" />
|
||||
Sections
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{event.targetedSections.map((sec: string) => (
|
||||
<span key={sec} className="px-3 py-1.5 bg-emerald-50 text-emerald-600 text-xs font-bold rounded-xl border border-emerald-100">
|
||||
Section {sec}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{event.guestName && (
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-text-dark mb-3">Special Guest</h3>
|
||||
<div className="p-4 rounded-2xl bg-slate-50 border border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-text-dark">{event.guestName}</p>
|
||||
{event.guestSocialProfile && (
|
||||
<a href={event.guestSocialProfile} target="_blank" rel="noopener noreferrer" className="text-xs font-bold text-brand-indigo hover:underline mt-0.5 inline-block">
|
||||
View Profile
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-slate-100 bg-slate-50/50 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-3 bg-white border border-slate-200 text-text-dark rounded-xl text-sm font-black hover:bg-slate-50 hover:border-slate-300 transition-all shadow-sm"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
387
RIT-EMS-main/frontend/src/components/dashboard/EventHistory.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
import { API_BASE_URL } from '../../lib/config';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
History,
|
||||
Search,
|
||||
Calendar,
|
||||
MapPin,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Download,
|
||||
Filter,
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
User,
|
||||
Heart,
|
||||
FileText,
|
||||
X,
|
||||
Building2,
|
||||
Users,
|
||||
Wallet,
|
||||
Ticket
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { format } from 'date-fns';
|
||||
import { Pagination } from './Pagination';
|
||||
|
||||
interface Event {
|
||||
id: number;
|
||||
title: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
type: string;
|
||||
status: string;
|
||||
department: string;
|
||||
location: string;
|
||||
budget: number;
|
||||
conflictMessage?: string;
|
||||
rejectionReason?: string;
|
||||
institution: string;
|
||||
category: string;
|
||||
academicYears: string[];
|
||||
proposer?: {
|
||||
fullName: string;
|
||||
};
|
||||
description?: string;
|
||||
sponsors?: string[];
|
||||
hasRegistrationFee: boolean;
|
||||
registrationFee: number;
|
||||
}
|
||||
|
||||
export const EventHistory: React.FC = () => {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('ALL');
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(API_BASE_URL + '/api/events')
|
||||
.then(res => res.json())
|
||||
.then(data => setEvents(data))
|
||||
.catch(err => console.error('History fetch failed:', err));
|
||||
}, []);
|
||||
|
||||
const getStatusStyle = (status: string, hasConflict: boolean) => {
|
||||
if (hasConflict && status !== 'APPROVED') return {
|
||||
bg: 'bg-red-50',
|
||||
text: 'text-red-600',
|
||||
border: 'border-red-100',
|
||||
icon: AlertTriangle,
|
||||
label: 'CONFLICT'
|
||||
};
|
||||
|
||||
switch (status) {
|
||||
case 'APPROVED': return { bg: 'bg-emerald-50', text: 'text-emerald-600', border: 'border-emerald-100', icon: CheckCircle2, label: 'APPROVED' };
|
||||
case 'REQUESTED': return { bg: 'bg-blue-50', text: 'text-blue-600', border: 'border-blue-100', icon: Clock, label: 'REQUESTED' };
|
||||
case 'PENDING_PR': return { bg: 'bg-purple-50', text: 'text-purple-600', border: 'border-purple-100', icon: ShieldCheck, label: 'PENDING PRINCIPAL' };
|
||||
case 'HOD_REJECTED':
|
||||
case 'PRINCIPAL_REJECTED': return { bg: 'bg-red-50', text: 'text-red-600', border: 'border-red-100', icon: XCircle, label: 'REJECTED' };
|
||||
default: return { bg: 'bg-slate-50', text: 'text-slate-600', border: 'border-slate-100', icon: Clock, label: status };
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEvents = events
|
||||
.filter(e => {
|
||||
const matchesSearch = e.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
e.department.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
if (statusFilter === 'ALL') return matchesSearch;
|
||||
if (statusFilter === 'PENDING') return matchesSearch && (e.status === 'REQUESTED' || e.status === 'PENDING_PR');
|
||||
if (statusFilter === 'REJECTED') return matchesSearch && e.status.includes('REJECTED');
|
||||
return matchesSearch && e.status === statusFilter;
|
||||
})
|
||||
.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||
|
||||
const currentEvents = filteredEvents.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-10">
|
||||
{/* Header Section */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="w-12 h-12 bg-brand-navy rounded-2xl flex items-center justify-center text-white premium-shadow">
|
||||
<History className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-text-dark tracking-tight">Institutional Event History</h2>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-text-muted">Audit-ready comprehensive event ledger</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-100 rounded-xl text-[10px] font-black uppercase tracking-widest text-text-muted hover:bg-slate-50 transition-all">
|
||||
<Download className="w-4 h-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Bar */}
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-6 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-300" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by event title or department..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full h-16 pl-16 pr-8 bg-white border border-slate-100 rounded-3xl text-sm font-bold text-text-dark focus:outline-none focus:ring-4 focus:ring-brand-indigo/5 focus:border-brand-indigo transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 bg-white p-2 rounded-3xl border border-slate-100">
|
||||
{['ALL', 'APPROVED', 'PENDING', 'REJECTED'].map(status => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setStatusFilter(status)}
|
||||
className={cn(
|
||||
"px-6 py-3 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all",
|
||||
statusFilter === status
|
||||
? "bg-brand-indigo text-white premium-shadow-sm"
|
||||
: "text-text-muted hover:bg-slate-50"
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events Ledger */}
|
||||
<div className="bg-white rounded-[2.5rem] border border-slate-100 premium-shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-50">
|
||||
<th className="px-8 py-6 text-left text-[10px] font-black uppercase tracking-widest text-text-muted">Event Details</th>
|
||||
<th className="px-8 py-6 text-left text-[10px] font-black uppercase tracking-widest text-text-muted">Logistics</th>
|
||||
<th className="px-8 py-6 text-left text-[10px] font-black uppercase tracking-widest text-text-muted">Status</th>
|
||||
<th className="px-8 py-6 text-right text-[10px] font-black uppercase tracking-widest text-text-muted">Budget</th>
|
||||
<th className="px-8 py-6 text-right text-[10px] font-black uppercase tracking-widest text-text-muted"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{currentEvents.map((event, idx) => {
|
||||
const statusStyle = getStatusStyle(event.status, !!event.conflictMessage);
|
||||
const StatusIcon = statusStyle.icon;
|
||||
|
||||
return (
|
||||
<motion.tr
|
||||
key={event.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.03 }}
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
className="group hover:bg-slate-50/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center font-black text-[10px]",
|
||||
event.conflictMessage ? "bg-red-50 text-red-500" : "bg-brand-indigo/5 text-brand-indigo"
|
||||
)}>
|
||||
{event.department?.slice(0, 3).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-black text-text-dark group-hover:text-brand-indigo transition-colors">{event.title}</p>
|
||||
<p className="text-[10px] font-bold text-text-muted uppercase tracking-widest mt-0.5">{event.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-bold">{format(new Date(event.startDate), 'MMM d, yyyy')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-bold">{event.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<div className={cn(
|
||||
"inline-flex items-center gap-2 px-3 py-1.5 rounded-full border text-[9px] font-black uppercase tracking-widest",
|
||||
statusStyle.bg, statusStyle.text, statusStyle.border
|
||||
)}>
|
||||
<StatusIcon className="w-3.5 h-3.5" />
|
||||
{statusStyle.label}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6 text-right">
|
||||
<span className="text-sm font-black text-text-dark">₹{event.budget?.toLocaleString() || '0'}</span>
|
||||
</td>
|
||||
<td className="px-8 py-6 text-right">
|
||||
<button className="p-2 hover:bg-white hover:premium-shadow-sm rounded-lg transition-all text-slate-300 group-hover:text-brand-indigo">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalItems={filteredEvents.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={(val) => {
|
||||
setItemsPerPage(val);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
itemsPerPageOptions={[5, 10, 20, 50]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedEvent && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setSelectedEvent(null)}
|
||||
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="relative w-full max-w-2xl bg-white rounded-[2.5rem] premium-shadow overflow-hidden max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<div className="p-8 bg-brand-navy text-white flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-white/60">Event History</span>
|
||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-white/60">{selectedEvent.institution}</span>
|
||||
</div>
|
||||
<h3 className="text-2xl font-black tracking-tight">{selectedEvent.title}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedEvent(null)}
|
||||
className="p-2 hover:bg-white/10 rounded-xl transition-all"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
{/* Description */}
|
||||
{selectedEvent.description && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
Description
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-dark leading-relaxed bg-slate-50 p-4 rounded-2xl border border-slate-100">
|
||||
{selectedEvent.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
Date & Time
|
||||
</div>
|
||||
<p className="text-sm font-bold text-text-dark">{new Date(selectedEvent.startDate).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
Location
|
||||
</div>
|
||||
<p className="text-sm font-bold text-text-dark">{selectedEvent.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
|
||||
<Building2 className="w-3.5 h-3.5" />
|
||||
Department
|
||||
</div>
|
||||
<p className="text-sm font-bold text-text-dark">{selectedEvent.department}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
Target Batches
|
||||
</div>
|
||||
<p className="text-sm font-bold text-text-dark">{selectedEvent.academicYears?.join(', ') || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sponsors */}
|
||||
{selectedEvent.sponsors && selectedEvent.sponsors.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
|
||||
<Heart className="w-3.5 h-3.5 text-brand-indigo" />
|
||||
Event Sponsors
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedEvent.sponsors.map((sponsor, idx) => (
|
||||
<span key={idx} className="px-3 py-1 bg-brand-glow text-brand-indigo rounded-lg text-[10px] font-black uppercase tracking-widest border border-brand-indigo/10">
|
||||
{sponsor}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 pt-4 border-t border-slate-50">
|
||||
<div className="p-4 bg-slate-50 rounded-2xl">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted mb-1">
|
||||
<Wallet className="w-3.5 h-3.5" />
|
||||
Budget
|
||||
</div>
|
||||
<p className="text-lg font-black text-text-dark">₹{selectedEvent.budget?.toLocaleString() || '0'}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-slate-50 rounded-2xl">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted mb-1">
|
||||
<Ticket className="w-3.5 h-3.5" />
|
||||
Status
|
||||
</div>
|
||||
<p className="text-xs font-black text-brand-indigo uppercase">{selectedEvent.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEvent.rejectionReason && (
|
||||
<div className="p-4 bg-red-50 border border-red-100 rounded-2xl">
|
||||
<p className="text-[10px] font-black uppercase text-red-500 mb-1">Rejection Reason</p>
|
||||
<p className="text-xs font-bold text-red-700">{selectedEvent.rejectionReason}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ShieldCheck = (props: any) => (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
1287
RIT-EMS-main/frontend/src/components/dashboard/EventProposalForm.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { CheckCircle2, Circle, AlertCircle, Clock, XCircle, ShieldCheck, Flag } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export type Event = {
|
||||
id: number;
|
||||
title: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
location: string;
|
||||
category: string;
|
||||
type: string;
|
||||
institution: string;
|
||||
department: string;
|
||||
status: 'REQUESTED' | 'APPROVED' | 'COMPLETED' | 'CANCELLED' | 'PENDING_PR' | 'HOD_REJECTED' | 'PRINCIPAL_REJECTED';
|
||||
rejectionReason?: string;
|
||||
guestName?: string;
|
||||
proposer?: {
|
||||
email: string;
|
||||
fullName: string;
|
||||
};
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export const StatusTimeline: React.FC<{ event: Event; compact?: boolean }> = ({ event, compact }) => {
|
||||
const stages = [
|
||||
{ id: 'REQUESTED', label: 'Proposal Raised', icon: Clock },
|
||||
{ id: 'PENDING_PR', label: 'HoD Review', icon: ShieldCheck },
|
||||
{ id: 'APPROVED', label: 'Principal Approval', icon: CheckCircle2 },
|
||||
{ id: 'COMPLETED', label: 'Event Success', icon: Flag }
|
||||
];
|
||||
|
||||
const parseDate = (dateSource: any): Date | null => {
|
||||
if (!dateSource) return null;
|
||||
if (typeof dateSource === 'string') return new Date(dateSource);
|
||||
if (Array.isArray(dateSource)) {
|
||||
return new Date(dateSource[0], dateSource[1] - 1, dateSource[2], dateSource[3] || 0, dateSource[4] || 0);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const endDate = parseDate(event.endDate);
|
||||
|
||||
const isAutoCompleted = event.status === 'APPROVED' && endDate && now > endDate;
|
||||
const isManuallyCompleted = event.status === 'COMPLETED';
|
||||
const effectiveStatus = (isAutoCompleted || isManuallyCompleted) ? 'COMPLETED' : event.status;
|
||||
|
||||
const isHODRejected = effectiveStatus === 'HOD_REJECTED';
|
||||
const isPRRejected = effectiveStatus === 'PRINCIPAL_REJECTED';
|
||||
const isCancelled = effectiveStatus === 'CANCELLED';
|
||||
const isRejected = isHODRejected || isPRRejected || isCancelled;
|
||||
|
||||
let currentStageIdx = stages.findIndex(s => s.id === effectiveStatus);
|
||||
if (currentStageIdx === -1) {
|
||||
if (isHODRejected) currentStageIdx = 1;
|
||||
else if (isPRRejected) currentStageIdx = 2;
|
||||
else if (isCancelled) currentStageIdx = 0;
|
||||
else currentStageIdx = 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-8", compact ? "py-2" : "py-6")}>
|
||||
<div className="relative flex items-center justify-between w-full px-4">
|
||||
<div className="absolute left-10 right-10 h-[2px] bg-slate-100 top-[18px]" />
|
||||
|
||||
{!isRejected && (
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `calc(${(currentStageIdx / (stages.length - 1)) * 100}%)` }}
|
||||
className="absolute left-10 h-[2px] bg-emerald-500 top-[18px] origin-left transition-all duration-1000"
|
||||
/>
|
||||
)}
|
||||
|
||||
{stages.map((stage, i) => {
|
||||
const isPast = i < currentStageIdx;
|
||||
const isCurrent = i === currentStageIdx;
|
||||
const isFuture = i > currentStageIdx;
|
||||
|
||||
const stageRejected = isRejected && isCurrent;
|
||||
const stageCompleted = isPast || (isCurrent && !isRejected);
|
||||
|
||||
return (
|
||||
<div key={stage.id} className="relative z-10 flex flex-col items-center">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
scale: isCurrent ? 1.1 : 1,
|
||||
backgroundColor: stageRejected ? "#ef4444" : stageCompleted ? "#10b981" : "#f1f5f9",
|
||||
borderColor: stageRejected ? "#fee2e2" : stageCompleted ? "#d1fae5" : "#fff"
|
||||
}}
|
||||
className={cn(
|
||||
"w-9 h-9 rounded-full flex items-center justify-center border-4 shadow-sm transition-all duration-500",
|
||||
stageRejected ? "text-white" : stageCompleted ? "text-white" : "text-slate-300"
|
||||
)}
|
||||
>
|
||||
{stageRejected ? <XCircle className="w-5 h-5" /> :
|
||||
stageCompleted ? <CheckCircle2 className="w-5 h-5" /> :
|
||||
<stage.icon className="w-4 h-4" />}
|
||||
</motion.div>
|
||||
|
||||
<div className="absolute top-12 flex flex-col items-center min-w-[100px]">
|
||||
<span className={cn(
|
||||
"text-[8px] font-black uppercase tracking-[0.2em] whitespace-nowrap",
|
||||
stageRejected ? "text-red-500" : stageCompleted ? "text-emerald-600" : "text-slate-400"
|
||||
)}>
|
||||
{stageRejected ? (isHODRejected ? 'HoD Rejected' : isPRRejected ? 'PR Rejected' : 'Cancelled') : stage.label}
|
||||
</span>
|
||||
|
||||
{isCurrent && !isRejected && (
|
||||
<motion.span
|
||||
animate={{ opacity: [0.4, 1, 0.4] }}
|
||||
transition={{ repeat: Infinity, duration: 2 }}
|
||||
className="text-[6px] font-bold text-brand-indigo mt-1 uppercase tracking-tighter"
|
||||
>
|
||||
Current Phase
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isRejected && event.rejectionReason && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-16 p-6 bg-red-50/50 border border-red-100 rounded-[2rem] flex gap-4 items-start"
|
||||
>
|
||||
<div className="w-10 h-10 bg-red-500 rounded-xl flex items-center justify-center shrink-0 shadow-lg shadow-red-200">
|
||||
<AlertCircle className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-red-500 mb-1">Feedback Message</p>
|
||||
<p className="text-sm font-bold text-red-700 leading-relaxed italic">"{event.rejectionReason}"</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1173
RIT-EMS-main/frontend/src/components/dashboard/ExcelImport.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Edit3,
|
||||
Calendar,
|
||||
MapPin,
|
||||
ChevronRight,
|
||||
ShieldCheck,
|
||||
XCircle,
|
||||
Timer,
|
||||
ChevronLeft,
|
||||
ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Event {
|
||||
id: number;
|
||||
title: string;
|
||||
startDate: any;
|
||||
endDate: any;
|
||||
location: string;
|
||||
status: 'REQUESTED' | 'APPROVED' | 'COMPLETED' | 'CANCELLED' | 'PENDING_PR' | 'HOD_REJECTED' | 'PRINCIPAL_REJECTED';
|
||||
rejectionReason?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface FacultyEventManagementProps {
|
||||
events: Event[];
|
||||
onEditEvent: (event: Event) => void;
|
||||
}
|
||||
|
||||
export const FacultyEventManagement: React.FC<FacultyEventManagementProps> = ({ events, onEditEvent }) => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(3);
|
||||
|
||||
const parseDate = (dateSource: any): Date | null => {
|
||||
if (!dateSource) return null;
|
||||
if (typeof dateSource === 'string') return new Date(dateSource);
|
||||
if (Array.isArray(dateSource)) {
|
||||
return new Date(dateSource[0], dateSource[1] - 1, dateSource[2], dateSource[3] || 0, dateSource[4] || 0);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const sortedEvents = useMemo(() => {
|
||||
return [...events].sort((a, b) => {
|
||||
const dateA = parseDate(a.startDate)?.getTime() || 0;
|
||||
const dateB = parseDate(b.startDate)?.getTime() || 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
}, [events]);
|
||||
|
||||
const totalPages = Math.ceil(sortedEvents.length / itemsPerPage);
|
||||
const currentEvents = sortedEvents.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
const rangeStart = events.length === 0 ? 0 : (currentPage - 1) * itemsPerPage + 1;
|
||||
const rangeEnd = Math.min(currentPage * itemsPerPage, events.length);
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'APPROVED':
|
||||
return { color: 'text-emerald-500', bg: 'bg-emerald-50', border: 'border-emerald-100', icon: CheckCircle2, label: 'Approved' };
|
||||
case 'PENDING_PR':
|
||||
return { color: 'text-brand-indigo', bg: 'bg-brand-glow', border: 'border-brand-indigo/10', icon: ShieldCheck, label: 'At Principal Desk' };
|
||||
case 'REQUESTED':
|
||||
return { color: 'text-amber-500', bg: 'bg-amber-50', border: 'border-amber-100', icon: Timer, label: 'At HoD Desk' };
|
||||
case 'HOD_REJECTED':
|
||||
case 'PRINCIPAL_REJECTED':
|
||||
return { color: 'text-red-500', bg: 'bg-red-50', border: 'border-red-100', icon: XCircle, label: 'Rejected' };
|
||||
case 'CANCELLED':
|
||||
return { color: 'text-slate-400', bg: 'bg-slate-50', border: 'border-slate-200', icon: XCircle, label: 'Cancelled' };
|
||||
default:
|
||||
return { color: 'text-slate-500', bg: 'bg-slate-50', border: 'border-slate-100', icon: Clock, label: status };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card-widget h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-brand-navy tracking-tight flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-brand-indigo" />
|
||||
My Event Proposals
|
||||
</h3>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400 mt-1">
|
||||
Track and manage your submitted requests
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-slate-50 px-3 py-1.5 rounded-xl border border-slate-100">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||
{events.length} Total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
{events.length === 0 ? (
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col items-center justify-center py-12 text-center"
|
||||
>
|
||||
<div className="w-16 h-16 bg-slate-50 rounded-2xl flex items-center justify-center mb-4">
|
||||
<Calendar className="w-8 h-8 text-slate-200" />
|
||||
</div>
|
||||
<p className="text-sm font-bold text-slate-400 italic">No event proposals yet.</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={currentPage + '-' + itemsPerPage}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
{currentEvents.map((event, idx) => {
|
||||
const config = getStatusConfig(event.status);
|
||||
const eventDate = parseDate(event.startDate);
|
||||
const canEdit = ['REQUESTED', 'PENDING_PR'].includes(event.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="group relative bg-white border border-slate-100 rounded-2xl p-4 hover:border-brand-indigo/30 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className={cn("px-2 py-0.5 rounded-md text-[8px] font-black uppercase tracking-widest border", config.bg, config.color, config.border)}>
|
||||
{config.label}
|
||||
</div>
|
||||
<span className="text-[9px] font-black text-slate-300 uppercase tracking-widest">•</span>
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">ID: #{event.id}</span>
|
||||
</div>
|
||||
<h4 className="text-sm font-black text-brand-navy truncate group-hover:text-brand-indigo transition-colors mb-2">
|
||||
{event.title}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||
<div className="flex items-center gap-1.5 text-slate-500">
|
||||
<Clock className="w-3.5 h-3.5 text-brand-indigo/60" />
|
||||
<span className="text-[10px] font-bold">
|
||||
{eventDate ? format(eventDate, 'MMM d, h:mm a') : 'TBD'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-slate-500">
|
||||
<MapPin className="w-3.5 h-3.5 text-brand-indigo/60" />
|
||||
<span className="text-[10px] font-bold truncate max-w-[120px]">
|
||||
{event.location}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{canEdit ? (
|
||||
<button
|
||||
onClick={() => onEditEvent(event)}
|
||||
className="p-2.5 bg-brand-glow text-brand-indigo rounded-xl hover:bg-brand-indigo hover:text-white transition-all shadow-sm group/btn"
|
||||
title="Edit Proposal"
|
||||
>
|
||||
<Edit3 className="w-4 h-4 transition-transform group-hover/btn:scale-110" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="p-2.5 bg-slate-50 text-slate-300 rounded-xl cursor-not-allowed" title="Cannot edit once in review">
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
<button className="p-2.5 bg-white border border-slate-100 text-slate-400 rounded-xl hover:bg-slate-50 hover:text-brand-indigo transition-all shadow-sm">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.rejectionReason && (
|
||||
<div className="mt-3 p-3 bg-red-50/50 border border-red-100 rounded-xl flex items-start gap-2">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-red-500 shrink-0 mt-0.5" />
|
||||
<p className="text-[10px] font-medium text-red-700 italic">
|
||||
"{event.rejectionReason}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-8 pt-4 border-t border-slate-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Rows per page</span>
|
||||
<div className="relative group">
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="bg-slate-50 border border-slate-100 rounded-xl px-4 py-2 text-[11px] font-black text-brand-navy appearance-none pr-10 cursor-pointer hover:border-brand-indigo/30 transition-all focus:outline-none"
|
||||
>
|
||||
{[3, 5, 10, 20].map(val => (
|
||||
<option key={val} value={val}>{val}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] font-black text-brand-navy tracking-tight min-w-[80px] text-center">
|
||||
{rangeStart}-{rangeEnd} <span className="text-slate-300 mx-1">of</span> {events.length}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 text-slate-400 hover:text-brand-indigo disabled:opacity-20 disabled:cursor-not-allowed transition-all hover:bg-slate-50 rounded-lg"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages || events.length === 0}
|
||||
className="p-2 text-slate-400 hover:text-brand-indigo disabled:opacity-20 disabled:cursor-not-allowed transition-all hover:bg-slate-50 rounded-lg"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,488 @@
|
||||
import { API_BASE_URL } from '../../lib/config';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
MessageSquare,
|
||||
Loader2,
|
||||
X,
|
||||
FilePlus,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { cn } from '../../lib/utils';
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
eachDayOfInterval,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
addMonths,
|
||||
subMonths,
|
||||
isWeekend
|
||||
} from 'date-fns';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
interface Event {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
status: string;
|
||||
department: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
interface Note {
|
||||
id: number;
|
||||
content: string;
|
||||
targetDate: string;
|
||||
authorName: string;
|
||||
}
|
||||
|
||||
interface InstitutionalCalendarProps {
|
||||
onProposeEvent?: (date: string) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const InstitutionalCalendar: React.FC<InstitutionalCalendarProps> = ({ onProposeEvent, compact }) => {
|
||||
const { user } = useAuth();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
|
||||
const [newNote, setNewNote] = useState('');
|
||||
const [isSubmittingNote, setIsSubmittingNote] = useState(false);
|
||||
|
||||
// Advanced Filters
|
||||
const [filters, setFilters] = useState({
|
||||
department: '',
|
||||
batch: '',
|
||||
section: ''
|
||||
});
|
||||
|
||||
// Rescheduling State
|
||||
const [reschedulingEvent, setReschedulingEvent] = useState<Event | null>(null);
|
||||
const [newTargetDate, setNewTargetDate] = useState('');
|
||||
const [startTime, setStartTime] = useState('10:00');
|
||||
const [endTime, setEndTime] = useState('12:00');
|
||||
|
||||
const parseDate = (dateSource: any): Date | null => {
|
||||
if (!dateSource) return null;
|
||||
if (typeof dateSource === 'string') return new Date(dateSource);
|
||||
if (Array.isArray(dateSource)) {
|
||||
// Handle Spring Boot LocalDateTime array: [yyyy, mm, dd, hh, mm, ss]
|
||||
return new Date(dateSource[0], dateSource[1] - 1, dateSource[2], dateSource[3] || 0, dateSource[4] || 0);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [eventsRes, notesRes] = await Promise.all([
|
||||
fetch(API_BASE_URL + '/api/events'),
|
||||
fetch(API_BASE_URL + '/api/notes')
|
||||
]);
|
||||
|
||||
if (eventsRes.ok && notesRes.ok) {
|
||||
const eventsData = await eventsRes.json();
|
||||
const notesData = await notesRes.json();
|
||||
|
||||
// Robust filtering and status check
|
||||
const filteredEvents = eventsData.filter((e: any) => {
|
||||
const status = e.status?.toUpperCase();
|
||||
return status === 'APPROVED' || status === 'COMPLETED';
|
||||
});
|
||||
|
||||
setEvents(filteredEvents);
|
||||
setNotes(notesData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch calendar data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleAddNote = async () => {
|
||||
if (!newNote.trim() || !selectedDay) return;
|
||||
setIsSubmittingNote(true);
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: newNote,
|
||||
targetDate: format(selectedDay, 'yyyy-MM-dd'),
|
||||
authorName: user?.fullName || 'Anonymous',
|
||||
authorEmail: user?.email
|
||||
})
|
||||
});
|
||||
if (response.ok) {
|
||||
setNewNote('');
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add note:', error);
|
||||
} finally {
|
||||
setIsSubmittingNote(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReschedule = async () => {
|
||||
if (!reschedulingEvent || !newTargetDate) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/events/${reschedulingEvent.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
startDate: `${newTargetDate}T${startTime}:00`,
|
||||
endDate: `${newTargetDate}T${endTime}:00`
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setReschedulingEvent(null);
|
||||
setNewTargetDate('');
|
||||
setSelectedDay(null);
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reschedule event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEvents = events.filter(e => {
|
||||
const matchesDept = !filters.department || e.department === filters.department;
|
||||
// We'd need to cast to any for targetedSections if not in interface
|
||||
const matchesBatch = !filters.batch || (e as any).academicYears?.includes(filters.batch);
|
||||
const matchesSection = !filters.section || (e as any).targetedSections?.includes(filters.section);
|
||||
return matchesDept && matchesBatch && matchesSection;
|
||||
});
|
||||
|
||||
const days = eachDayOfInterval({
|
||||
start: startOfWeek(startOfMonth(currentDate)),
|
||||
end: endOfWeek(endOfMonth(currentDate))
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-20 gap-4">
|
||||
<Loader2 className="w-10 h-10 text-brand-indigo animate-spin" />
|
||||
<p className="text-sm font-black text-brand-indigo uppercase tracking-widest">Loading Institutional Schedule...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-8", compact && "space-y-4")}>
|
||||
{/* Header & Advanced Filters */}
|
||||
{!compact && (
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-brand-indigo rounded-2xl flex items-center justify-center text-white premium-shadow">
|
||||
<CalendarIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-text-dark tracking-tight">Event & Notes Manager</h2>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-text-muted">Master schedule audit & granular management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={filters.department}
|
||||
onChange={(e) => setFilters({...filters, department: e.target.value})}
|
||||
className="bg-white border border-slate-100 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest text-text-dark outline-none premium-shadow-sm"
|
||||
>
|
||||
<option value="">All Departments</option>
|
||||
{Array.from(new Set(events.map(e => e.department))).sort().map(d => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filters.batch}
|
||||
onChange={(e) => setFilters({...filters, batch: e.target.value})}
|
||||
className="bg-white border border-slate-100 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest text-text-dark outline-none premium-shadow-sm"
|
||||
>
|
||||
<option value="">All Batches</option>
|
||||
{['1st Year', '2nd Year', '3rd Year', '4th Year'].map(b => (
|
||||
<option key={b} value={b}>{b}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filters.section}
|
||||
onChange={(e) => setFilters({...filters, section: e.target.value})}
|
||||
className="bg-white border border-slate-100 px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest text-text-dark outline-none premium-shadow-sm"
|
||||
>
|
||||
<option value="">All Sections</option>
|
||||
{['A', 'B', 'C', 'D'].map(s => (
|
||||
<option key={s} value={s}>Section {s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn(
|
||||
"bg-white rounded-[2.5rem] border border-slate-100 premium-shadow overflow-hidden",
|
||||
compact && "rounded-none shadow-none border-none"
|
||||
)}>
|
||||
{/* Calendar Navigation */}
|
||||
<div className={cn("flex items-center justify-between border-b border-slate-50", compact ? "p-4" : "p-8")}>
|
||||
<h3 className={cn("font-black text-text-dark tracking-tighter", compact ? "text-xl" : "text-3xl")}>
|
||||
{format(currentDate, 'MMMM yyyy')}
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentDate(subMonths(currentDate, 1))}
|
||||
className={cn("hover:bg-slate-50 rounded-2xl transition-all border border-slate-100", compact ? "p-2" : "p-3")}
|
||||
>
|
||||
<ChevronLeft className={cn("text-text-muted", compact ? "w-4 h-4" : "w-5 h-5")} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentDate(addMonths(currentDate, 1))}
|
||||
className={cn("hover:bg-slate-50 rounded-2xl transition-all border border-slate-100", compact ? "p-2" : "p-3")}
|
||||
>
|
||||
<ChevronRight className={cn("text-text-muted", compact ? "w-4 h-4" : "w-5 h-5")} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className={cn(compact ? "p-4" : "p-8")}>
|
||||
<div className="grid grid-cols-7 gap-px bg-slate-100 border border-slate-100 rounded-3xl overflow-hidden">
|
||||
{['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'].map((day, i) => (
|
||||
<div key={`${day}-${i}`} className="bg-white py-4 text-center text-[10px] font-black text-text-muted tracking-widest border-b border-slate-50">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
{days.map((day, idx) => {
|
||||
const dayStr = format(day, 'yyyy-MM-dd');
|
||||
const dayEvents = filteredEvents.filter(e => {
|
||||
const date = parseDate(e.startDate);
|
||||
return date && format(date, 'yyyy-MM-dd') === dayStr;
|
||||
});
|
||||
const dayNotes = notes.filter(n => {
|
||||
const date = parseDate(n.targetDate);
|
||||
return date && format(date, 'yyyy-MM-dd') === dayStr;
|
||||
});
|
||||
const isToday = isSameDay(day, new Date());
|
||||
const isCurrentMonth = isSameMonth(day, currentDate);
|
||||
const weekend = isWeekend(day);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toString()}
|
||||
onClick={() => setSelectedDay(day)}
|
||||
className={cn(
|
||||
compact ? "min-h-[80px] p-2" : "min-h-[140px] p-4",
|
||||
"bg-white hover:bg-slate-50/50 transition-all cursor-pointer group relative",
|
||||
!isCurrentMonth && "opacity-30 grayscale pointer-events-none",
|
||||
isToday && "bg-brand-glow/10"
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className={cn(
|
||||
"text-sm font-black",
|
||||
isToday ? "text-brand-indigo" : "text-text-dark",
|
||||
weekend && !isToday && "text-red-400"
|
||||
)}>
|
||||
{format(day, 'd')}
|
||||
</span>
|
||||
{weekend && isCurrentMonth && (
|
||||
<span className="text-[8px] font-black text-red-300 uppercase tracking-widest">Holiday</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Day Content */}
|
||||
<div className="space-y-1.5 overflow-y-auto max-h-[100px] custom-scrollbar pr-1">
|
||||
{dayEvents.map(event => (
|
||||
<div key={event.id} className="p-2.5 bg-brand-glow/20 border border-brand-indigo/10 rounded-xl transition-all hover:bg-brand-glow/40">
|
||||
<p className="text-[10px] font-black text-brand-indigo leading-tight truncate">{event.title}</p>
|
||||
<p className="text-[8px] font-black text-brand-indigo/60 uppercase tracking-widest mt-0.5">
|
||||
{event.department === 'AI&DS' ? 'AI&DS' : event.department?.split(' ')[0]}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{dayNotes.map(note => (
|
||||
<div key={note.id} className="p-2 bg-amber-50 border border-amber-200 rounded-lg flex gap-2 items-start">
|
||||
<MessageSquare className="w-2.5 h-2.5 text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-[9px] font-bold text-amber-700 leading-tight">{note.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Hover Action Indicator */}
|
||||
<div className="absolute inset-0 border-2 border-brand-indigo/0 group-hover:border-brand-indigo/10 rounded-xl transition-all pointer-events-none" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{selectedDay && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-6 bg-slate-900/40 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="bg-white w-full max-w-md rounded-[2.5rem] premium-shadow-2xl overflow-hidden p-10 space-y-8"
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black text-text-dark tracking-tight">Add Event Info</h3>
|
||||
<p className="text-sm font-bold text-text-muted mt-1">Details for {format(selectedDay, 'yyyy-MM-dd')}</p>
|
||||
</div>
|
||||
<button onClick={() => setSelectedDay(null)} className="p-2 hover:bg-slate-50 rounded-xl transition-all">
|
||||
<X className="w-6 h-6 text-slate-300" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Event List in Modal */}
|
||||
<div className="space-y-4 max-h-[300px] overflow-y-auto custom-scrollbar pr-2">
|
||||
{filteredEvents.filter(e => {
|
||||
const date = parseDate(e.startDate);
|
||||
return date && format(date, 'yyyy-MM-dd') === format(selectedDay, 'yyyy-MM-dd');
|
||||
}).map(event => (
|
||||
<div key={event.id} className="flex flex-col gap-4 p-5 bg-slate-50 border border-slate-100 rounded-[2rem] transition-all hover:bg-white hover:premium-shadow group">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-1.5 h-8 bg-brand-indigo rounded-full" />
|
||||
<div>
|
||||
<p className="text-sm font-black text-text-dark">{event.title}</p>
|
||||
<p className="text-[8px] font-black uppercase tracking-widest text-text-muted mt-0.5">{event.department} • {event.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setReschedulingEvent(event);
|
||||
setNewTargetDate(format(selectedDay, 'yyyy-MM-dd'));
|
||||
const d = parseDate(event.startDate);
|
||||
if (d) {
|
||||
setStartTime(format(d, 'HH:mm'));
|
||||
}
|
||||
const ed = parseDate(event.endDate);
|
||||
if (ed) {
|
||||
setEndTime(format(ed, 'HH:mm'));
|
||||
}
|
||||
}}
|
||||
className="p-2.5 bg-white rounded-xl border border-slate-100 text-brand-indigo hover:bg-brand-indigo hover:text-white transition-all premium-shadow-sm"
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{reschedulingEvent?.id === event.id && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className="pt-4 border-t border-slate-100 space-y-4"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-[8px] font-black uppercase tracking-widest text-text-muted mb-2 block">Reschedule Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newTargetDate}
|
||||
onChange={(e) => setNewTargetDate(e.target.value)}
|
||||
className="w-full bg-white border border-slate-200 rounded-xl p-3 text-[10px] font-black text-text-dark focus:outline-none focus:ring-2 focus:ring-brand-indigo/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[8px] font-black uppercase tracking-widest text-text-muted mb-2 block">Start Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="w-full bg-white border border-slate-200 rounded-xl p-3 text-[10px] font-black text-text-dark focus:outline-none focus:ring-2 focus:ring-brand-indigo/10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[8px] font-black uppercase tracking-widest text-text-muted mb-2 block">End Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="w-full bg-white border border-slate-200 rounded-xl p-3 text-[10px] font-black text-text-dark focus:outline-none focus:ring-2 focus:ring-brand-indigo/10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleReschedule}
|
||||
className="w-full py-4 bg-brand-indigo text-white rounded-xl text-[9px] font-black uppercase tracking-widest premium-shadow active:scale-95 transition-all mt-2"
|
||||
>
|
||||
Apply Schedule Change
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Note Input */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted">New Note</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newNote}
|
||||
onChange={(e) => setNewNote(e.target.value)}
|
||||
placeholder="e.g. AI Workshop"
|
||||
className="w-full h-16 px-6 bg-white border-2 border-brand-indigo/30 rounded-2xl text-sm font-bold text-text-dark focus:outline-none focus:border-brand-indigo transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end gap-6 pt-4">
|
||||
<button
|
||||
onClick={() => setSelectedDay(null)}
|
||||
className="text-sm font-black text-text-dark/60 hover:text-text-dark transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddNote}
|
||||
disabled={!newNote.trim() || isSubmittingNote}
|
||||
className="px-8 py-4 bg-brand-indigo text-white rounded-3xl font-black text-sm premium-shadow-sm hover:translate-y-[-2px] active:translate-y-[0px] transition-all disabled:opacity-50"
|
||||
>
|
||||
{isSubmittingNote ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Save Note'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Propose Action Fallback */}
|
||||
<div className="pt-6 border-t border-slate-50 text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onProposeEvent) onProposeEvent(format(selectedDay, 'yyyy-MM-dd'));
|
||||
setSelectedDay(null);
|
||||
}}
|
||||
className="text-[10px] font-black uppercase tracking-widest text-brand-indigo hover:underline"
|
||||
>
|
||||
+ Propose New Event for this date
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,367 @@
|
||||
import { API_BASE_URL } from '../../lib/config';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
PlusCircle,
|
||||
ChevronRight,
|
||||
ShieldCheck,
|
||||
Split,
|
||||
Globe,
|
||||
Layers,
|
||||
ArrowUpRight,
|
||||
Calendar,
|
||||
ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { Pagination } from './Pagination';
|
||||
|
||||
interface Event {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
status: string;
|
||||
department: string;
|
||||
institution: string;
|
||||
academicYears: string[];
|
||||
targetedSections: string[];
|
||||
category: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface ClassMapping {
|
||||
institution: string;
|
||||
department: string;
|
||||
academicYear: string;
|
||||
sections: string[];
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'GUEST_LECTURE', label: 'Guest Lectures', matchTypes: ['Guest Lecture', 'Workshop', 'Seminar'] },
|
||||
{ id: 'ALUMNI_LECTURE', label: 'Alumni Lectures', matchTypes: ['Alumni Lecture'] },
|
||||
{ id: 'CONFERENCE', label: 'Conference', matchTypes: ['Conference'] },
|
||||
{ id: 'INDUSTRIAL_VISIT', label: 'Industrial Visit', matchTypes: ['Industrial Visit'] },
|
||||
{ id: 'TECHFEST', label: 'Techfest', matchTypes: ['Techfest'] },
|
||||
{ id: 'PROF_SOCIETY', label: 'Prof. Society Activities', matchTypes: ['Club', 'Professional Society'] },
|
||||
{ id: 'INSTITUTIONAL', label: 'Institutional Events', matchTypes: ['Institutional'] },
|
||||
];
|
||||
|
||||
const ALL_DEPARTMENTS = [
|
||||
'AI&DS', 'AI&ML', 'CSE', 'CCE', 'CSBS', 'ECE', 'MECH', 'EE(VLSI)', 'BIOTECH', 'Placement Department', 'H&S Dept'
|
||||
];
|
||||
|
||||
const DepartmentCard: React.FC<{
|
||||
deptName: string;
|
||||
events: Event[];
|
||||
selectedBatch: string;
|
||||
index: number;
|
||||
isApplicable: boolean;
|
||||
sections: string[];
|
||||
onIncompleteClick?: (data: any) => void;
|
||||
isCompact?: boolean;
|
||||
}> = ({ deptName, events, selectedBatch, index, isApplicable, sections, onIncompleteClick, isCompact }) => {
|
||||
const departmentEvents = events.filter(e => {
|
||||
const status = e.status?.toUpperCase();
|
||||
const isDeptMatch = e.department?.trim().toLowerCase() === deptName.trim().toLowerCase();
|
||||
const isInstitutional = e.category?.toUpperCase() === 'INSTITUTIONAL';
|
||||
|
||||
return (isDeptMatch || isInstitutional) &&
|
||||
(status === 'APPROVED' || status === 'COMPLETED' || status === 'PENDING_PR');
|
||||
});
|
||||
|
||||
// Base requirement is 1 event per category per batch, or 2 if multiple sections exist
|
||||
const getRequiredCount = (catId: string) => {
|
||||
if (catId === 'INSTITUTIONAL') return 1;
|
||||
return sections.length > 1 ? 2 : 1;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05, duration: 0.5 }}
|
||||
className={cn(
|
||||
"bg-white rounded-[2rem] flex flex-col h-full relative group transition-all",
|
||||
isCompact ? "border-none shadow-none p-0" : "border border-slate-100 overflow-hidden premium-shadow p-0"
|
||||
)}
|
||||
>
|
||||
<div className={cn("p-6 border-b border-slate-50", isCompact ? "bg-white p-0 pb-4 border-none" : "bg-slate-50/30 p-8")}>
|
||||
<div className="flex items-center justify-between gap-4 mb-2">
|
||||
<h3 className={cn("font-black text-text-dark group-hover:text-brand-indigo transition-colors", isCompact ? "text-lg" : "text-2xl tracking-tight")}>{deptName}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isApplicable ? (
|
||||
<span className="px-3 py-1 bg-amber-50 text-amber-600 rounded-full border border-amber-100 text-[8px] font-black uppercase tracking-widest">Inert Batch</span>
|
||||
) : (
|
||||
<span className="px-3 py-1 bg-brand-glow text-brand-indigo rounded-full border border-brand-indigo/10 text-[8px] font-black uppercase tracking-widest flex items-center gap-1.5">
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
{sections.length} Sections
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-text-muted">
|
||||
Academic Year Profile: <span className="text-brand-indigo">{selectedBatch}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={cn("space-y-6 flex-1 bg-white", isCompact ? "p-0" : "p-8")}>
|
||||
{CATEGORIES.map((cat) => {
|
||||
const matchingEvents = departmentEvents.filter(event => {
|
||||
const matchesType = cat.matchTypes.some(mt => mt.toLowerCase() === event.type?.toLowerCase());
|
||||
const eventBatches = event.academicYears?.map(y => y.trim().toLowerCase()) || [];
|
||||
const isInstitutional = event.category?.toUpperCase() === 'INSTITUTIONAL';
|
||||
const batchMatches = eventBatches.length === 0 || eventBatches.includes(selectedBatch.trim().toLowerCase());
|
||||
|
||||
return matchesType && (isInstitutional ? batchMatches : eventBatches.includes(selectedBatch.trim().toLowerCase()));
|
||||
});
|
||||
|
||||
const currentCount = matchingEvents.length;
|
||||
|
||||
let coveredSections = new Set<string>();
|
||||
matchingEvents.forEach(e => {
|
||||
if (e.category === 'INSTITUTIONAL' || !e.targetedSections || e.targetedSections.length === 0) {
|
||||
sections.forEach(s => coveredSections.add(s));
|
||||
} else {
|
||||
e.targetedSections.forEach(s => coveredSections.add(s));
|
||||
}
|
||||
});
|
||||
|
||||
const eventsRequired = getRequiredCount(cat.id);
|
||||
const missingSections = sections.filter(s => !coveredSections.has(s));
|
||||
const isFullyCovered = (missingSections.length === 0 && currentCount > 0) || (currentCount >= eventsRequired);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="group/item relative"
|
||||
onClick={() => !isFullyCovered && onIncompleteClick?.({
|
||||
department: deptName,
|
||||
eventType: cat.matchTypes[0],
|
||||
academicYears: [selectedBatch]
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center transition-all border shadow-sm",
|
||||
isFullyCovered ? "bg-emerald-500 border-emerald-400 text-white" : currentCount > 0 ? "bg-brand-indigo border-brand-indigo text-white" : "bg-white border-slate-100 text-slate-300"
|
||||
)}>
|
||||
{isFullyCovered ? <ShieldCheck className="w-5 h-5" /> : currentCount > 0 ? <Layers className="w-5 h-5" /> : <Circle className="w-5 h-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<span className={cn(
|
||||
"text-sm font-black tracking-tight",
|
||||
isFullyCovered ? "text-text-dark" : currentCount > 0 ? "text-brand-indigo" : "text-text-muted"
|
||||
)}>
|
||||
{cat.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={cn(
|
||||
"text-[8px] font-black uppercase tracking-[0.1em]",
|
||||
isFullyCovered ? "text-emerald-500" : currentCount > 0 ? "text-brand-indigo" : "text-slate-400"
|
||||
)}>
|
||||
{isFullyCovered ? 'Complete Coverage' : missingSections.length > 0 && currentCount > 0 ? 'Partial Coverage' : 'Awaiting Proposals'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<div className={cn(
|
||||
"px-3 py-1.5 rounded-xl text-[10px] font-black tracking-widest border transition-all",
|
||||
isFullyCovered ? "bg-emerald-50 text-emerald-600 border-emerald-100" : currentCount > 0 ? "bg-brand-glow text-brand-indigo border-brand-indigo/10" : "bg-slate-50 text-text-muted border-slate-100"
|
||||
)}>
|
||||
{currentCount}/{eventsRequired}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 bg-slate-100 rounded-full overflow-hidden mb-3">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.min((currentCount / eventsRequired) * 100, 100)}%` }}
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-1000",
|
||||
isFullyCovered ? "bg-emerald-500" : "bg-brand-indigo"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{missingSections.length > 0 && currentCount > 0 && (
|
||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} className="flex items-start gap-2 p-3 bg-red-50 rounded-xl border border-red-100 mt-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-500 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[9px] font-black text-red-600 uppercase tracking-widest">Sections Left Out</p>
|
||||
<p className="text-[10px] font-bold text-red-700">{missingSections.join(', ')}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const InstitutionalChecklist: React.FC<{
|
||||
isCompact?: boolean;
|
||||
isGlobalView?: boolean;
|
||||
onIncompleteClick?: (data: any) => void;
|
||||
}> = ({ isCompact, isGlobalView, onIncompleteClick }) => {
|
||||
const { user } = useAuth();
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [classes, setClasses] = useState<ClassMapping[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedBatch, setSelectedBatch] = useState<string>('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(6);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [eventsRes, classesRes] = await Promise.all([
|
||||
fetch(API_BASE_URL + '/api/events'),
|
||||
fetch(API_BASE_URL + '/api/classes')
|
||||
]);
|
||||
|
||||
if (eventsRes.ok && classesRes.ok) {
|
||||
const [eventsData, classesData] = await Promise.all([
|
||||
eventsRes.json(),
|
||||
classesRes.json()
|
||||
]);
|
||||
setEvents(eventsData);
|
||||
setClasses(classesData);
|
||||
|
||||
const batches = Array.from(new Set(classesData.map((c: any) => c.academicYear.trim()))).sort().reverse();
|
||||
if (batches.length > 0 && !selectedBatch) {
|
||||
setSelectedBatch(batches[0] as string);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch checklist data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const allBatches = useMemo(() => {
|
||||
return Array.from(new Set(classes.map(c => c.academicYear.trim()))).sort().reverse();
|
||||
}, [classes]);
|
||||
|
||||
const filteredDepartments = useMemo(() => {
|
||||
let depts = ALL_DEPARTMENTS;
|
||||
if (user?.role === 'PRINCIPAL' || user?.role === 'ADMIN') depts = ALL_DEPARTMENTS;
|
||||
else if (isGlobalView && user?.role === 'HOD') depts = ALL_DEPARTMENTS;
|
||||
else depts = ALL_DEPARTMENTS.filter(d => d.toLowerCase().includes(user?.department?.toLowerCase() || ''));
|
||||
|
||||
// Recent items at top logic: Sort departments by the date of their latest event
|
||||
return [...depts].sort((a, b) => {
|
||||
const latestA = events.filter(e => e.department === a || e.category === 'INSTITUTIONAL').reduce((max, e) => Math.max(max, new Date(e.updatedAt || e.createdAt || 0).getTime()), 0);
|
||||
const latestB = events.filter(e => e.department === b || e.category === 'INSTITUTIONAL').reduce((max, e) => Math.max(max, new Date(e.updatedAt || e.createdAt || 0).getTime()), 0);
|
||||
return latestB - latestA;
|
||||
});
|
||||
}, [user, isGlobalView, events]);
|
||||
|
||||
const currentDepts = filteredDepartments.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-20">
|
||||
<Loader2 className="w-8 h-8 text-brand-indigo animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-8", isCompact ? "space-y-4" : "space-y-8")}>
|
||||
{!isCompact && (
|
||||
<div className="bg-white p-8 rounded-[3rem] border border-slate-100 premium-shadow flex flex-col md:flex-row md:items-center justify-between gap-8 relative overflow-hidden">
|
||||
{/* Adjusted Badge Position to prevent overlapping with dropdown */}
|
||||
<div className="absolute top-4 left-4">
|
||||
<div className="px-3 py-1 bg-emerald-50 text-emerald-600 rounded-full border border-emerald-100 flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-emerald-500 rounded-full animate-pulse" />
|
||||
<span className="text-[8px] font-black uppercase tracking-widest whitespace-nowrap">Audit Engine Live</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 md:pt-0">
|
||||
<h2 className="text-3xl font-black text-text-dark tracking-tight mb-2">Academic Audit</h2>
|
||||
<p className="text-xs font-bold text-text-muted flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-500" />
|
||||
Monitoring department-wide compliance and session coverage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 bg-slate-50 p-4 rounded-3xl group relative min-w-[240px]">
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-text-muted mb-1 ml-2">Audit Year</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedBatch}
|
||||
onChange={(e) => setSelectedBatch(e.target.value)}
|
||||
className="w-full bg-transparent text-brand-indigo font-black text-lg outline-none cursor-pointer appearance-none relative z-10 pr-10"
|
||||
>
|
||||
{allBatches.map(batch => <option key={batch} value={batch}>{batch}</option>)}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 text-brand-indigo pointer-events-none transition-transform group-hover:translate-y-[-40%]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-brand-indigo text-white rounded-xl flex items-center justify-center shadow-lg group-hover:scale-110 transition-all shrink-0">
|
||||
<Calendar className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn(
|
||||
"grid",
|
||||
isCompact
|
||||
? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10"
|
||||
)}>
|
||||
{currentDepts.map((deptName, idx) => {
|
||||
const deptClasses = classes.filter(c => c.department === deptName && c.academicYear.trim() === selectedBatch.trim());
|
||||
const sections = deptClasses.reduce((acc, c) => [...acc, ...c.sections], [] as string[]);
|
||||
|
||||
return (
|
||||
<DepartmentCard
|
||||
key={`${deptName}-${selectedBatch}`}
|
||||
deptName={deptName}
|
||||
events={events}
|
||||
selectedBatch={selectedBatch}
|
||||
index={idx}
|
||||
isApplicable={deptClasses.length > 0}
|
||||
sections={sections}
|
||||
onIncompleteClick={onIncompleteClick}
|
||||
isCompact={isCompact}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalItems={filteredDepartments.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={(val) => {
|
||||
setItemsPerPage(val);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
itemsPerPageOptions={[3, 6, 9, 12]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
429
RIT-EMS-main/frontend/src/components/dashboard/Overview.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
import { API_BASE_URL } from '../../lib/config';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
Users,
|
||||
MapPin,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
ArrowUpRight,
|
||||
Target,
|
||||
BarChart3,
|
||||
LayoutDashboard,
|
||||
Bell,
|
||||
GraduationCap,
|
||||
Save,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { InstitutionalChecklist } from './InstitutionalChecklist';
|
||||
import { StatusTimeline, type Event } from './EventStatusTimeline';
|
||||
import { InstitutionalCalendar } from './InstitutionalCalendar';
|
||||
import { VenueTimeline } from './VenueTimeline';
|
||||
import { PrincipalMasterChecklist } from './PrincipalMasterChecklist';
|
||||
import { VenuesAtGlance } from './VenuesAtGlance';
|
||||
import { FacultyEventManagement } from './FacultyEventManagement';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface OverviewProps {
|
||||
userEvents: any[];
|
||||
onNavigate: (id: string) => void;
|
||||
onIncompleteClick: (data: any) => void;
|
||||
onCalendarPropose: (date: string) => void;
|
||||
onClubEventClick: (data: any) => void;
|
||||
onEditEvent?: (event: any) => void;
|
||||
}
|
||||
|
||||
export const Overview: React.FC<OverviewProps> = ({
|
||||
userEvents,
|
||||
onNavigate,
|
||||
onIncompleteClick,
|
||||
onCalendarPropose,
|
||||
onClubEventClick,
|
||||
onEditEvent
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const [stats, setStats] = useState({
|
||||
upcoming: 0,
|
||||
pending: 0,
|
||||
completed: 0,
|
||||
budget: '₹0'
|
||||
});
|
||||
const [alerts, setAlerts] = useState<Array<{type: string, msg: string, time: string}>>([]);
|
||||
const { login } = useAuth();
|
||||
const [strengthInput, setStrengthInput] = useState<number>(user?.classStrength || 0);
|
||||
const [isSavingStrength, setIsSavingStrength] = useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
const handleSaveStrength = async () => {
|
||||
if (!user) return;
|
||||
setIsSavingStrength(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/admin/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...user, classStrength: strengthInput })
|
||||
});
|
||||
if (response.ok) {
|
||||
const updatedUser = await response.json();
|
||||
login(updatedUser);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update class strength:', error);
|
||||
} finally {
|
||||
setIsSavingStrength(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const parseDate = (dateSource: any): Date | null => {
|
||||
if (!dateSource) return null;
|
||||
if (typeof dateSource === 'string') return new Date(dateSource);
|
||||
if (Array.isArray(dateSource)) {
|
||||
return new Date(dateSource[0], dateSource[1] - 1, dateSource[2], dateSource[3] || 0, dateSource[4] || 0);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const calculateStats = (data: any[]) => {
|
||||
if (!Array.isArray(data)) return;
|
||||
const now = new Date();
|
||||
|
||||
const upcoming = data.filter(e => {
|
||||
const d = parseDate(e.startDate);
|
||||
return e.status === 'APPROVED' && d && d > now;
|
||||
}).length;
|
||||
|
||||
const pending = data.filter(e => e.status === 'REQUESTED' || e.status === 'PENDING_PR').length;
|
||||
|
||||
const completed = data.filter(e => {
|
||||
const d = parseDate(e.endDate);
|
||||
return e.status === 'APPROVED' && d && d < now;
|
||||
}).length;
|
||||
|
||||
const totalBudget = data
|
||||
.filter(e => e.status === 'APPROVED')
|
||||
.reduce((acc, e) => acc + (e.budget || 0), 0);
|
||||
|
||||
let budgetDisplay = `₹${totalBudget.toLocaleString()}`;
|
||||
if (totalBudget >= 100000) {
|
||||
budgetDisplay = `₹${(totalBudget / 100000).toFixed(1)}L`;
|
||||
} else if (totalBudget >= 1000) {
|
||||
budgetDisplay = `₹${(totalBudget / 1000).toFixed(1)}K`;
|
||||
}
|
||||
|
||||
setStats({
|
||||
upcoming,
|
||||
pending,
|
||||
completed,
|
||||
budget: budgetDisplay
|
||||
});
|
||||
|
||||
const generatedAlerts: any[] = [];
|
||||
const sortedEvents = [...data].sort((a, b) => {
|
||||
return new Date(b.updatedAt || b.createdAt || 0).getTime() - new Date(a.updatedAt || a.createdAt || 0).getTime();
|
||||
});
|
||||
|
||||
for (const event of sortedEvents) {
|
||||
if (generatedAlerts.length >= 4) break;
|
||||
const dateToUse = event.updatedAt || event.createdAt;
|
||||
if (!dateToUse) continue;
|
||||
|
||||
let timeStr = formatDistanceToNow(new Date(dateToUse), { addSuffix: true })
|
||||
.replace('about ', '')
|
||||
.replace(' minutes', 'm')
|
||||
.replace(' minute', 'm')
|
||||
.replace(' hours', 'h')
|
||||
.replace(' hour', 'h')
|
||||
.replace(' days', 'd')
|
||||
.replace(' day', 'd');
|
||||
|
||||
if (event.conflictMessage) {
|
||||
generatedAlerts.push({ type: 'warning', msg: `Conflict: ${event.title}`, time: timeStr });
|
||||
} else if (event.status === 'REJECTED') {
|
||||
generatedAlerts.push({ type: 'error', msg: `Rejected: ${event.title}`, time: timeStr });
|
||||
} else if (event.status === 'APPROVED') {
|
||||
generatedAlerts.push({ type: 'success', msg: `Approved: ${event.title}`, time: timeStr });
|
||||
} else if (event.status === 'REQUESTED' || event.status === 'PENDING_PR') {
|
||||
generatedAlerts.push({ type: 'info', msg: `New proposal: ${event.title}`, time: timeStr });
|
||||
}
|
||||
}
|
||||
|
||||
if (generatedAlerts.length === 0) {
|
||||
generatedAlerts.push({ type: 'info', msg: 'No recent activity', time: 'Just now' });
|
||||
}
|
||||
|
||||
setAlerts(generatedAlerts);
|
||||
};
|
||||
|
||||
if (userEvents && userEvents.length > 0) {
|
||||
calculateStats(userEvents);
|
||||
} else {
|
||||
fetch(API_BASE_URL + '/api/events')
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (Array.isArray(data)) {
|
||||
const statsData = (user?.role === 'PRINCIPAL' || user?.role === 'ADMIN')
|
||||
? data
|
||||
: data.filter(e => e.proposer?.email === user?.email || e.department === user?.department);
|
||||
calculateStats(statsData);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Stats fetch error:", err));
|
||||
}
|
||||
}, [userEvents, user]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-700">
|
||||
{/* Welcome Bar - Subtle */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-brand-navy tracking-tight">
|
||||
Welcome back, <span className="text-brand-indigo">{user?.fullName?.split(' ')[0]}</span>
|
||||
</h2>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">
|
||||
Institutional Dashboard • {format(new Date(), 'EEEE, MMMM do')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Principal Master Checklist - Only for Principal Desk */}
|
||||
{user?.role === 'PRINCIPAL' && (
|
||||
<PrincipalMasterChecklist />
|
||||
)}
|
||||
|
||||
{user?.role === 'FACULTY' && user?.isClassIncharge && (
|
||||
<div className="bg-brand-indigo/5 border border-brand-indigo/10 rounded-[1.5rem] p-6 mb-8 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-brand-indigo text-white flex items-center justify-center shrink-0 shadow-sm">
|
||||
<GraduationCap className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-brand-navy tracking-tight">Class Incharge Dashboard</h3>
|
||||
<p className="text-xs font-bold text-slate-500 mt-1">
|
||||
Assigned Class: <span className="text-brand-indigo uppercase tracking-wider">{user.inchargeClass} • {user.inchargeBatch} • Sec {user.inchargeSection}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 bg-white p-2 pr-4 rounded-xl shadow-sm border border-slate-100">
|
||||
<div className="flex flex-col pl-3">
|
||||
<label className="text-[9px] font-black uppercase tracking-widest text-slate-400">Class Strength</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={strengthInput || ''}
|
||||
onChange={(e) => setStrengthInput(parseInt(e.target.value) || 0)}
|
||||
className="w-20 text-lg font-black text-brand-navy focus:outline-none placeholder:text-slate-200"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveStrength}
|
||||
disabled={isSavingStrength || strengthInput === user.classStrength}
|
||||
className={cn(
|
||||
"p-2.5 rounded-lg transition-all flex items-center justify-center text-white min-w-[40px]",
|
||||
saveSuccess ? "bg-emerald-500" :
|
||||
strengthInput !== user.classStrength ? "bg-brand-indigo hover:shadow-md hover:scale-105 active:scale-95" :
|
||||
"bg-slate-200 text-slate-400"
|
||||
)}
|
||||
>
|
||||
{isSavingStrength ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> :
|
||||
saveSuccess ? <Check className="w-4 h-4" /> :
|
||||
<Save className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
{/* Row 1: Live Status and Analytics */}
|
||||
<div className="lg:col-span-12 flex flex-col gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="w-full">
|
||||
<div className="card-widget">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="font-black text-brand-navy flex items-center gap-2">
|
||||
Live Status
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="text-[10px] font-black text-emerald-500 uppercase tracking-widest">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ label: 'Upcoming', value: stats.upcoming, icon: CalendarIcon, color: 'text-indigo-600', bg: 'bg-indigo-50' },
|
||||
{ label: 'Pending', value: stats.pending, icon: Bell, color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||
{ label: 'Finished', value: stats.completed, icon: CheckCircle2, color: 'text-emerald-600', bg: 'bg-emerald-50' },
|
||||
{ label: 'Budget', value: stats.budget, icon: TrendingUp, color: 'text-slate-600', bg: 'bg-slate-50' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className={cn("p-4 rounded-2xl border border-slate-100/50", stat.bg)}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<stat.icon className={cn("w-4 h-4", stat.color)} />
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400">{stat.label}</span>
|
||||
</div>
|
||||
<p className="text-xl font-black text-brand-navy">{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-brand-glow rounded-2xl border border-brand-indigo/10">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Zap className="w-4 h-4 text-brand-indigo" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-brand-indigo">Quick Insight</span>
|
||||
</div>
|
||||
<p className="text-[11px] font-bold text-brand-indigo/70 leading-relaxed">
|
||||
{stats.pending > 0
|
||||
? `You have ${stats.pending} proposals waiting for your review. Processing them now ensures timely venue booking.`
|
||||
: "All systems clear. Your departmental compliance is currently at 100% for the current cycle."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<div className="card-widget">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="font-black text-brand-navy flex items-center gap-2">
|
||||
Analytics
|
||||
</h3>
|
||||
<BarChart3 className="w-4 h-4 text-slate-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<div className="relative w-32 h-32 flex items-center justify-center">
|
||||
<svg className="w-full h-full -rotate-90">
|
||||
<circle cx="64" cy="64" r="58" fill="transparent" stroke="#f1f5f9" strokeWidth="12" />
|
||||
<circle cx="64" cy="64" r="58" fill="transparent" stroke="#4f46e5" strokeWidth="12" strokeDasharray="364.4" strokeDashoffset={364.4 * (1 - 0.65)} strokeLinecap="round" />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-2xl font-black text-brand-navy">{userEvents.length}</span>
|
||||
<span className="text-[8px] font-black text-slate-400 uppercase tracking-widest">Events</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-3 mt-8 w-full">
|
||||
{[
|
||||
{ label: 'Technical', color: 'bg-brand-indigo' },
|
||||
{ label: 'Cultural', color: 'bg-emerald-500' },
|
||||
{ label: 'Sports', color: 'bg-amber-500' },
|
||||
{ label: 'Other', color: 'bg-slate-400' },
|
||||
].map(cat => (
|
||||
<div key={cat.label} className="flex items-center gap-2">
|
||||
<div className={cn("w-2 h-2 rounded-full", cat.color)} />
|
||||
<span className="text-[10px] font-bold text-slate-500">{cat.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Today's Tasks / My Proposals */}
|
||||
<div className="lg:col-span-12">
|
||||
{(user?.role === 'PRINCIPAL' || user?.role === 'ADMIN') ? (
|
||||
<div className="card-widget flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="font-black text-brand-navy flex items-center gap-2">
|
||||
Today's Tasks
|
||||
<span className="bg-slate-100 text-slate-500 text-[10px] px-2 py-0.5 rounded-full">
|
||||
{stats.pending}
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => onNavigate('checklist')}
|
||||
className="text-[10px] font-black text-brand-indigo uppercase tracking-widest hover:underline"
|
||||
>
|
||||
See All
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<InstitutionalChecklist isCompact onIncompleteClick={onIncompleteClick} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<FacultyEventManagement
|
||||
events={userEvents}
|
||||
onEditEvent={onEditEvent || (() => {})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-12">
|
||||
<VenuesAtGlance events={userEvents} />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Upcoming Events & Alerts */}
|
||||
|
||||
|
||||
<div className="lg:col-span-8">
|
||||
<div className="card-widget">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-brand-navy tracking-tight">Institutional Schedule</h3>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400 mt-1">Unified view of all approved events & academic notes</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="px-3 py-1.5 bg-slate-50 rounded-xl border border-slate-100">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Master Feed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[1.5rem] overflow-hidden border border-slate-50">
|
||||
<InstitutionalCalendar compact onProposeEvent={onCalendarPropose} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-4">
|
||||
<div className="card-widget h-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="font-black text-brand-navy flex items-center gap-2">
|
||||
Recent Alerts
|
||||
</h3>
|
||||
<button className="text-[10px] font-black text-brand-indigo uppercase tracking-widest hover:underline">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{alerts.map((alert, i) => (
|
||||
<div key={i} className="flex gap-4 group">
|
||||
<div className={cn(
|
||||
"w-1 h-8 rounded-full mt-1 shrink-0",
|
||||
alert.type === 'success' ? 'bg-emerald-500' :
|
||||
alert.type === 'warning' ? 'bg-amber-500' :
|
||||
alert.type === 'error' ? 'bg-red-500' : 'bg-brand-indigo'
|
||||
)} />
|
||||
<div>
|
||||
<p className="text-xs font-bold text-brand-navy leading-snug group-hover:text-brand-indigo transition-colors">{alert.msg}</p>
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest">{alert.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onItemsPerPageChange: (items: number) => void;
|
||||
itemsPerPageOptions?: number[];
|
||||
}
|
||||
|
||||
export const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
onPageChange,
|
||||
onItemsPerPageChange,
|
||||
itemsPerPageOptions = [5, 10, 20, 50]
|
||||
}) => {
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
const rangeStart = totalItems === 0 ? 0 : (currentPage - 1) * itemsPerPage + 1;
|
||||
const rangeEnd = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
if (totalItems === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-8 py-4 px-8 border-t border-slate-50 bg-slate-50/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Rows per page</span>
|
||||
<div className="relative group">
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => onItemsPerPageChange(Number(e.target.value))}
|
||||
className="bg-white border border-slate-200 rounded-xl px-4 py-2 text-[11px] font-black text-brand-navy appearance-none pr-10 cursor-pointer hover:border-brand-indigo/30 transition-all focus:outline-none premium-shadow-sm"
|
||||
>
|
||||
{itemsPerPageOptions.map(val => (
|
||||
<option key={val} value={val}>{val}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] font-black text-brand-navy tracking-tight min-w-[80px] text-center">
|
||||
{rangeStart}-{rangeEnd} <span className="text-slate-300 mx-1">of</span> {totalItems}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 text-slate-400 hover:text-brand-indigo disabled:opacity-20 disabled:cursor-not-allowed transition-all hover:bg-slate-50 rounded-lg"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 text-slate-400 hover:text-brand-indigo disabled:opacity-20 disabled:cursor-not-allowed transition-all hover:bg-slate-50 rounded-lg"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
ShieldCheck,
|
||||
ChevronRight,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { API_BASE_URL } from '../../lib/config';
|
||||
import { INSTITUTIONAL_EVENTS } from '../../constants/institutionalEvents';
|
||||
import { getCurrentAcademicYear } from '../../lib/dateUtils';
|
||||
|
||||
interface Event {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
academicYears: string[];
|
||||
startDate: string;
|
||||
}
|
||||
|
||||
export const PrincipalMasterChecklist: React.FC = () => {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const currentAcademicYear = getCurrentAcademicYear();
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, []);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/api/events');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setEvents(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch events:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getEventStatus = (eventName: string) => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const matchingEvents = events.filter(e => {
|
||||
const matchesName = e.title.toLowerCase().includes(eventName.toLowerCase());
|
||||
const eventDate = new Date(e.startDate);
|
||||
// Check if event is within the current academic cycle (roughly current year +/- 1)
|
||||
const matchesCycle = eventDate.getFullYear() === currentYear ||
|
||||
(eventDate.getFullYear() === currentYear - 1 && eventDate.getMonth() >= 5) ||
|
||||
(eventDate.getFullYear() === currentYear + 1 && eventDate.getMonth() < 5);
|
||||
return matchesName && matchesCycle;
|
||||
});
|
||||
|
||||
if (matchingEvents.some(e => e.status?.toUpperCase() === 'COMPLETED')) return 'COMPLETED';
|
||||
if (matchingEvents.some(e => e.status?.toUpperCase() === 'APPROVED')) return 'APPROVED';
|
||||
if (matchingEvents.some(e => e.status?.toUpperCase() === 'PENDING_PR' || e.status?.toUpperCase() === 'REQUESTED')) return 'PENDING';
|
||||
return 'NOT_SCHEDULED';
|
||||
};
|
||||
|
||||
const oddSemesterEvents = INSTITUTIONAL_EVENTS.filter(e => e.semester === 'ODD');
|
||||
const evenSemesterEvents = INSTITUTIONAL_EVENTS.filter(e => e.semester === 'EVEN');
|
||||
|
||||
const SemesterCard = ({ title, events }: { title: string, events: typeof INSTITUTIONAL_EVENTS }) => (
|
||||
<div className="flex-1 bg-white rounded-[2.5rem] border border-slate-100 premium-shadow overflow-hidden flex flex-col">
|
||||
<div className="p-8 border-b border-slate-50 bg-slate-50/30">
|
||||
<h3 className="text-xl font-black text-brand-navy tracking-tight">{title}</h3>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400 mt-1">Institutional Milestones</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-4 flex-1 overflow-y-auto max-h-[500px] custom-scrollbar">
|
||||
{events.map((event, i) => {
|
||||
const status = getEventStatus(event.name);
|
||||
return (
|
||||
<motion.div
|
||||
key={event.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="flex items-center justify-between group p-4 hover:bg-slate-50 rounded-2xl transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center transition-all",
|
||||
status === 'COMPLETED' ? "bg-emerald-500 text-white" :
|
||||
status === 'APPROVED' ? "bg-brand-indigo text-white" :
|
||||
status === 'PENDING' ? "bg-amber-500 text-white" : "bg-slate-100 text-slate-300"
|
||||
)}>
|
||||
{status === 'COMPLETED' ? <CheckCircle2 className="w-5 h-5" /> :
|
||||
status === 'APPROVED' ? <Calendar className="w-5 h-5" /> :
|
||||
status === 'PENDING' ? <Clock className="w-5 h-5" /> : <Circle className="w-5 h-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-black text-brand-navy group-hover:text-brand-indigo transition-colors">{event.name}</p>
|
||||
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">TARGET: {event.month}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"px-3 py-1 rounded-lg text-[8px] font-black uppercase tracking-widest border transition-all",
|
||||
status === 'COMPLETED' ? "bg-emerald-50 text-emerald-600 border-emerald-100" :
|
||||
status === 'APPROVED' ? "bg-brand-glow text-brand-indigo border-brand-indigo/10" :
|
||||
status === 'PENDING' ? "bg-amber-50 text-amber-600 border-amber-100" : "bg-slate-50 text-slate-400 border-slate-100"
|
||||
)}>
|
||||
{status.replace('_', ' ')}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-20">
|
||||
<Loader2 className="w-8 h-8 text-brand-indigo animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="bg-brand-navy p-10 rounded-[3rem] text-white flex flex-col md:flex-row md:items-center justify-between gap-8 relative overflow-hidden">
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-3xl font-black tracking-tight mb-2">Institutional Master Checklist</h2>
|
||||
<p className="text-sm font-medium text-white/60 flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
||||
Strategic event tracking for Academic Year {currentAcademicYear}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="px-5 py-3 bg-white/10 rounded-2xl border border-white/10">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em]">Principal Desk Control</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-brand-indigo/20 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
|
||||
<SemesterCard title="ODD Semester Milestones" events={oddSemesterEvents} />
|
||||
<SemesterCard title="EVEN Semester Milestones" events={evenSemesterEvents} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,611 @@
|
||||
import { API_BASE_URL } from '../../lib/config';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
X,
|
||||
Check,
|
||||
Search,
|
||||
Shield,
|
||||
Mail,
|
||||
Building2,
|
||||
Lock,
|
||||
Zap,
|
||||
GraduationCap
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { CLUBS } from '../../constants/clubs';
|
||||
import { Pagination } from './Pagination';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
fullName: string;
|
||||
email: string;
|
||||
role: string;
|
||||
department: string;
|
||||
password?: string;
|
||||
isClubCoordinator?: boolean;
|
||||
isPlacementStaff?: boolean;
|
||||
isClassIncharge?: boolean;
|
||||
inchargeClass?: string;
|
||||
inchargeBatch?: string;
|
||||
inchargeSection?: string;
|
||||
classStrength?: number;
|
||||
assignedClubs?: string[];
|
||||
}
|
||||
|
||||
export const UserManagement: React.FC = () => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [classes, setClasses] = useState<any[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
const [formData, setFormData] = useState({
|
||||
fullName: '',
|
||||
email: '',
|
||||
role: 'FACULTY',
|
||||
department: '',
|
||||
password: '',
|
||||
isClubCoordinator: false,
|
||||
isPlacementStaff: false,
|
||||
isClassIncharge: false,
|
||||
inchargeClass: '',
|
||||
inchargeBatch: '',
|
||||
inchargeSection: '',
|
||||
classStrength: 0,
|
||||
assignedClubs: [] as string[]
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
fetchClasses();
|
||||
}, []);
|
||||
|
||||
const fetchClasses = async () => {
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/api/classes');
|
||||
if (response.ok) {
|
||||
setClasses(await response.json());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch classes:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/api/admin/users');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenModal = (user: User | null = null) => {
|
||||
if (user) {
|
||||
setEditingUser(user);
|
||||
setFormData({
|
||||
fullName: user.fullName,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
department: user.department || '',
|
||||
password: '',
|
||||
isClubCoordinator: user.isClubCoordinator || false,
|
||||
isPlacementStaff: user.isPlacementStaff || false,
|
||||
isClassIncharge: user.isClassIncharge || false,
|
||||
inchargeClass: user.inchargeClass || '',
|
||||
inchargeBatch: user.inchargeBatch || '',
|
||||
inchargeSection: user.inchargeSection || '',
|
||||
classStrength: user.classStrength || 0,
|
||||
assignedClubs: user.assignedClubs || []
|
||||
});
|
||||
} else {
|
||||
setEditingUser(null);
|
||||
setFormData({
|
||||
fullName: '',
|
||||
email: '',
|
||||
role: 'FACULTY',
|
||||
department: '',
|
||||
password: '',
|
||||
isClubCoordinator: false,
|
||||
isPlacementStaff: false,
|
||||
isClassIncharge: false,
|
||||
inchargeClass: '',
|
||||
inchargeBatch: '',
|
||||
inchargeSection: '',
|
||||
classStrength: 0,
|
||||
assignedClubs: []
|
||||
});
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const url = editingUser
|
||||
? `${API_BASE_URL}/api/admin/users/${editingUser.id}`
|
||||
: API_BASE_URL + '/api/admin/users';
|
||||
|
||||
const method = editingUser ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchUsers();
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Operation failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (window.confirm('Are you sure you want to delete this user?')) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/admin/users/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
setUsers(prev => prev.filter(u => u.id !== id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users
|
||||
.filter(u =>
|
||||
u.fullName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => b.id - a.id);
|
||||
|
||||
const currentUsers = filteredUsers.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-text-dark tracking-tight">User Management</h2>
|
||||
<p className="text-text-muted font-medium">Add and manage institutional roles and permissions.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleOpenModal()}
|
||||
className="flex items-center gap-2 bg-brand-navy text-white px-6 py-3 rounded-xl font-black text-[10px] uppercase tracking-widest premium-shadow hover:scale-105 transition-all"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
Add New User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-[2.5rem] border border-slate-100 premium-shadow overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-50 flex items-center justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users by name or email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-slate-50 border-transparent rounded-xl py-2 pl-10 pr-4 text-sm focus:bg-white focus:border-brand-indigo transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] font-black uppercase tracking-widest text-text-muted">
|
||||
Total Users: {users.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50/50">
|
||||
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-[0.2em] text-text-muted">User Info</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-[0.2em] text-text-muted">Role</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-[0.2em] text-text-muted">Department</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-[0.2em] text-text-muted">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{currentUsers.map((user) => (
|
||||
<tr key={user.id} className="group hover:bg-slate-50/50 transition-colors">
|
||||
<td className="px-8 py-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-brand-glow flex items-center justify-center text-brand-indigo font-black text-xs">
|
||||
{user.fullName.split(' ').map(n => n[0]).join('').substring(0, 2)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-text-dark text-sm">{user.fullName}</span>
|
||||
<span className="text-xs text-text-muted">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5">
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<span className={cn(
|
||||
"px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border",
|
||||
user.role === 'ADMIN' ? 'bg-purple-50 text-purple-600 border-purple-100' :
|
||||
user.role === 'HOD' ? 'bg-amber-50 text-amber-600 border-amber-100' :
|
||||
user.role === 'PRINCIPAL' ? 'bg-emerald-50 text-emerald-600 border-emerald-100' :
|
||||
'bg-blue-50 text-blue-600 border-blue-100'
|
||||
)}>
|
||||
{user.role}
|
||||
</span>
|
||||
{user.isClassIncharge && (
|
||||
<span className="px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border bg-indigo-50 text-indigo-600 border-indigo-100 flex items-center gap-1">
|
||||
<GraduationCap className="w-3 h-3" />
|
||||
Class Incharge ({user.inchargeClass} {user.inchargeBatch} - Sec {user.inchargeSection})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 text-sm font-bold text-text-secondary">
|
||||
{user.department || 'N/A'}
|
||||
</td>
|
||||
<td className="px-8 py-5">
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleOpenModal(user)}
|
||||
className="p-2 hover:bg-white rounded-lg hover:premium-shadow-sm transition-all text-slate-400 hover:text-brand-indigo"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
{user.role !== 'ADMIN' && (
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="p-2 hover:bg-white rounded-lg hover:premium-shadow-sm transition-all text-slate-400 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalItems={filteredUsers.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={(val) => {
|
||||
setItemsPerPage(val);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
itemsPerPageOptions={[5, 10, 20, 50]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Modal */}
|
||||
<AnimatePresence>
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="relative w-full max-w-lg bg-white rounded-[2.5rem] premium-shadow overflow-hidden"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="p-8 bg-brand-navy text-white flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black tracking-tight">
|
||||
{editingUser ? 'Edit User' : 'Add New User'}
|
||||
</h3>
|
||||
<p className="text-white/60 text-xs font-medium mt-1">Configure institutional access credentials.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="p-2 hover:bg-white/10 rounded-xl transition-all"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-5">
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Full Name</label>
|
||||
<div className="relative">
|
||||
<Shield className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.fullName}
|
||||
onChange={(e) => setFormData({...formData, fullName: e.target.value})}
|
||||
placeholder="E.g. Dr. Jane Smith"
|
||||
className="w-full bg-slate-50 border-transparent rounded-xl py-3 pl-12 pr-4 text-sm focus:bg-white focus:border-brand-indigo transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Email Address</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({...formData, email: e.target.value})}
|
||||
placeholder="jane.smith@rit.edu"
|
||||
className="w-full bg-slate-50 border-transparent rounded-xl py-3 pl-12 pr-4 text-sm focus:bg-white focus:border-brand-indigo transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Role</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({...formData, role: e.target.value})}
|
||||
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-sm focus:bg-white focus:border-brand-indigo transition-all appearance-none"
|
||||
>
|
||||
<option value="FACULTY">Faculty</option>
|
||||
<option value="HOD">HoD</option>
|
||||
<option value="PRINCIPAL">Principal</option>
|
||||
<option value="PLACEMENT">Placement</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Department</label>
|
||||
<select
|
||||
required
|
||||
value={formData.department}
|
||||
onChange={(e) => setFormData({...formData, department: e.target.value})}
|
||||
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-sm focus:bg-white focus:border-brand-indigo transition-all appearance-none"
|
||||
>
|
||||
<option value="">Select Department</option>
|
||||
{[
|
||||
"AI&DS", "AI&ML", "CSE", "CCE", "CSBS", "ECE",
|
||||
"MECH", "EE(VLSI)", "BIOTECH", "Placement Department",
|
||||
"H&S Dept", "Club", "Centre"
|
||||
].map(dept => (
|
||||
<option key={dept} value={dept}>{dept}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">
|
||||
{editingUser ? 'New Password (Optional)' : 'Password'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
type="password"
|
||||
required={!editingUser}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({...formData, password: e.target.value})}
|
||||
placeholder="••••••••"
|
||||
className="w-full bg-slate-50 border-transparent rounded-xl py-3 pl-12 pr-4 text-sm focus:bg-white focus:border-brand-indigo transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center justify-between p-4 bg-slate-50/50 rounded-2xl border border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center transition-all",
|
||||
formData.isClubCoordinator ? "bg-brand-indigo text-white" : "bg-slate-200 text-text-muted"
|
||||
)}>
|
||||
<Shield className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-black text-text-dark leading-tight">Club Coordinator</p>
|
||||
<p className="text-[9px] font-bold text-text-muted">Proposals Permission</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({...formData, isClubCoordinator: !formData.isClubCoordinator})}
|
||||
className={cn(
|
||||
"w-10 h-5 rounded-full p-1 transition-all duration-300",
|
||||
formData.isClubCoordinator ? "bg-brand-indigo" : "bg-slate-300"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-3 h-3 bg-white rounded-full transition-all duration-300 transform",
|
||||
formData.isClubCoordinator ? "translate-x-5" : "translate-x-0"
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-slate-50/50 rounded-2xl border border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center transition-all",
|
||||
formData.isPlacementStaff ? "bg-brand-indigo text-white" : "bg-slate-200 text-text-muted"
|
||||
)}>
|
||||
<Zap className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-black text-text-dark leading-tight">Placement Coordinator</p>
|
||||
<p className="text-[9px] font-bold text-text-muted">High Priority Access</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({...formData, isPlacementStaff: !formData.isPlacementStaff})}
|
||||
className={cn(
|
||||
"w-10 h-5 rounded-full p-1 transition-all duration-300",
|
||||
formData.isPlacementStaff ? "bg-brand-indigo" : "bg-slate-300"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-3 h-3 bg-white rounded-full transition-all duration-300 transform",
|
||||
formData.isPlacementStaff ? "translate-x-5" : "translate-x-0"
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.role === 'FACULTY' && (
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center justify-between p-4 bg-slate-50/50 rounded-2xl border border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center transition-all",
|
||||
formData.isClassIncharge ? "bg-brand-indigo text-white" : "bg-slate-200 text-text-muted"
|
||||
)}>
|
||||
<GraduationCap className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-black text-text-dark leading-tight">Class Incharge</p>
|
||||
<p className="text-[9px] font-bold text-text-muted">Manage Class Operations</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({...formData, isClassIncharge: !formData.isClassIncharge})}
|
||||
className={cn(
|
||||
"w-10 h-5 rounded-full p-1 transition-all duration-300",
|
||||
formData.isClassIncharge ? "bg-brand-indigo" : "bg-slate-300"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-3 h-3 bg-white rounded-full transition-all duration-300 transform",
|
||||
formData.isClassIncharge ? "translate-x-5" : "translate-x-0"
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.isClassIncharge && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className="mt-4 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Class / Department</label>
|
||||
<select
|
||||
required={formData.isClassIncharge}
|
||||
value={formData.inchargeClass}
|
||||
onChange={(e) => setFormData({...formData, inchargeClass: e.target.value, inchargeBatch: '', inchargeSection: ''})}
|
||||
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-sm focus:bg-white focus:border-brand-indigo transition-all appearance-none"
|
||||
>
|
||||
<option value="">Select Class</option>
|
||||
{Array.from(new Set(classes.map(c => c.department))).map(dept => (
|
||||
<option key={dept as string} value={dept as string}>{dept as string}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Batch / Year</label>
|
||||
<select
|
||||
required={formData.isClassIncharge}
|
||||
value={formData.inchargeBatch}
|
||||
onChange={(e) => setFormData({...formData, inchargeBatch: e.target.value, inchargeSection: ''})}
|
||||
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-sm focus:bg-white focus:border-brand-indigo transition-all appearance-none"
|
||||
>
|
||||
<option value="">Select Batch</option>
|
||||
{Array.from(new Set(classes.filter(c => c.department === formData.inchargeClass).map(c => c.academicYear))).map(year => (
|
||||
<option key={year as string} value={year as string}>{year as string}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Section</label>
|
||||
<select
|
||||
required={formData.isClassIncharge}
|
||||
value={formData.inchargeSection}
|
||||
onChange={(e) => setFormData({...formData, inchargeSection: e.target.value})}
|
||||
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-sm focus:bg-white focus:border-brand-indigo transition-all appearance-none"
|
||||
>
|
||||
<option value="">Select Section</option>
|
||||
{(classes.find(c => c.department === formData.inchargeClass && c.academicYear === formData.inchargeBatch)?.sections || []).map((sec: string) => (
|
||||
<option key={sec} value={sec}>{sec}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.isClubCoordinator && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className="mt-4 space-y-3"
|
||||
>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted block">Assigned Clubs</label>
|
||||
<div className="flex flex-wrap gap-2 p-4 bg-slate-50/50 rounded-2xl border border-slate-100 min-h-[60px]">
|
||||
{CLUBS.map(club => (
|
||||
<button
|
||||
key={club}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updated = formData.assignedClubs.includes(club)
|
||||
? formData.assignedClubs.filter(c => c !== club)
|
||||
: [...formData.assignedClubs, club];
|
||||
setFormData({...formData, assignedClubs: updated});
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-lg text-[9px] font-black uppercase tracking-widest transition-all border",
|
||||
formData.assignedClubs.includes(club)
|
||||
? "bg-brand-navy text-white border-brand-navy premium-shadow-sm"
|
||||
: "bg-white text-text-muted border-slate-200 hover:border-brand-indigo/30"
|
||||
)}
|
||||
>
|
||||
{club}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-brand-navy text-white rounded-xl py-4 font-black text-[10px] uppercase tracking-widest premium-shadow hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||
>
|
||||
{editingUser ? 'Update User Credentials' : 'Create System User'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
289
RIT-EMS-main/frontend/src/components/dashboard/VenueTimeline.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { API_BASE_URL } from '../../lib/config';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Calendar as CalendarIcon, MapPin, Clock, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { format } from 'date-fns';
|
||||
import { startOfMonth } from 'date-fns';
|
||||
import { endOfMonth } from 'date-fns';
|
||||
import { startOfWeek } from 'date-fns';
|
||||
import { endOfWeek } from 'date-fns';
|
||||
import { eachDayOfInterval } from 'date-fns';
|
||||
import { isSameMonth } from 'date-fns';
|
||||
import { isSameDay } from 'date-fns';
|
||||
import { addMonths } from 'date-fns';
|
||||
import { subMonths } from 'date-fns';
|
||||
|
||||
interface Event {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
status: string;
|
||||
department: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
location: string;
|
||||
academicYears: string[];
|
||||
}
|
||||
|
||||
const CORE_VENUES = [
|
||||
'GB 4th floor auditorium',
|
||||
'Wozniak Auditorium',
|
||||
'C6-02 Indoor Theatre',
|
||||
'H Block Guest Lecture Theatre',
|
||||
'Steve Jobs Computer Centre 1',
|
||||
'Steve Jobs Computer Centre 2'
|
||||
];
|
||||
|
||||
export const VenueTimeline: React.FC<{ compact?: boolean }> = ({ compact }) => {
|
||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/api/events');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setEvents(data.filter((e: any) =>
|
||||
e.status === 'APPROVED' || e.status === 'COMPLETED'
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch events for timeline:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchEvents();
|
||||
}, []);
|
||||
|
||||
const parseDate = (dateSource: any): Date | null => {
|
||||
if (!dateSource) return null;
|
||||
if (typeof dateSource === 'string') return new Date(dateSource);
|
||||
if (Array.isArray(dateSource)) {
|
||||
return new Date(dateSource[0], dateSource[1] - 1, dateSource[2], dateSource[3] || 0, dateSource[4] || 0);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const eventsOnSelectedDate = events.filter(event => {
|
||||
const eventDate = parseDate(event.startDate);
|
||||
if (!eventDate) return false;
|
||||
return format(eventDate, 'yyyy-MM-dd') === format(selectedDate, 'yyyy-MM-dd');
|
||||
});
|
||||
|
||||
// Dynamically discover all unique venues from events on the selected date and combine with CORE_VENUES
|
||||
const allVenues = (() => {
|
||||
const dynamicVenues = eventsOnSelectedDate
|
||||
.map(e => e.location?.trim())
|
||||
.filter(l => l && l !== 'N/A' && l !== 'Others.') as string[];
|
||||
|
||||
const finalVenues = [...CORE_VENUES];
|
||||
dynamicVenues.forEach(dv => {
|
||||
if (!finalVenues.some(cv => cv.toLowerCase() === dv.toLowerCase())) {
|
||||
finalVenues.push(dv);
|
||||
}
|
||||
});
|
||||
return finalVenues;
|
||||
})();
|
||||
|
||||
const calendarDays = (() => {
|
||||
const start = startOfWeek(startOfMonth(currentMonth));
|
||||
const end = endOfWeek(endOfMonth(currentMonth));
|
||||
return eachDayOfInterval({ start, end });
|
||||
})();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-20">
|
||||
<Loader2 className="w-8 h-8 text-brand-indigo animate-spin" />
|
||||
<span className="ml-3 text-sm font-black text-brand-indigo uppercase tracking-widest tracking-widest">Initialising Timeline Audit...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex gap-8 items-start",
|
||||
compact ? "flex-col" : "flex-col lg:flex-row min-h-[600px]"
|
||||
)}>
|
||||
{/* Sidebar Filter - STICKY */}
|
||||
{!compact && (
|
||||
<div className="w-full lg:w-80 space-y-6 sticky top-24 self-start">
|
||||
<div className="bg-white p-8 rounded-[2.5rem] border border-slate-100 premium-shadow">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-10 h-10 bg-brand-indigo rounded-2xl flex items-center justify-center text-white premium-shadow-sm">
|
||||
<CalendarIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-text-muted">Timeline Filter</p>
|
||||
<h3 className="text-lg font-black text-text-dark tracking-tight">View Date</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h4 className="text-sm font-black text-text-dark">{format(currentMonth, 'MMMM yyyy')}</h4>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setCurrentMonth(subMonths(currentMonth, 1))} className="p-1 hover:bg-slate-50 rounded-lg transition-colors">
|
||||
<ChevronLeft className="w-4 h-4 text-text-muted" />
|
||||
</button>
|
||||
<button onClick={() => setCurrentMonth(addMonths(currentMonth, 1))} className="p-1 hover:bg-slate-50 rounded-lg transition-colors">
|
||||
<ChevronRight className="w-4 h-4 text-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
|
||||
<div key={`${day}-${i}`} className="text-[10px] font-black text-text-muted text-center py-2">{day}</div>
|
||||
))}
|
||||
{calendarDays.map(day => {
|
||||
const hasEvents = events.some(e =>
|
||||
e.startDate && format(new Date(e.startDate), 'yyyy-MM-dd') === format(day, 'yyyy-MM-dd')
|
||||
);
|
||||
const isSelected = isSameDay(day, selectedDate);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => setSelectedDate(day)}
|
||||
className={cn(
|
||||
"aspect-square flex items-center justify-center text-xs font-bold rounded-xl transition-all relative group",
|
||||
!isSameMonth(day, currentMonth) && "text-slate-200",
|
||||
isSelected ? "bg-brand-indigo text-white premium-shadow-sm" : "hover:bg-slate-50 text-text-dark",
|
||||
isSameDay(day, new Date()) && !isSelected && "text-brand-indigo underline decoration-2 underline-offset-4"
|
||||
)}
|
||||
>
|
||||
{format(day, 'd')}
|
||||
{hasEvents && (
|
||||
<div className={cn(
|
||||
"absolute bottom-1 w-1 h-1 rounded-full",
|
||||
isSelected ? "bg-white/40" : "bg-brand-indigo/30"
|
||||
)} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 p-4 bg-brand-glow/30 rounded-2xl border border-brand-indigo/10">
|
||||
<p className="text-[9px] font-bold text-brand-indigo/80 leading-relaxed italic">
|
||||
Tip: This calendar specifically filters the venue bookings timeline below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 space-y-6">
|
||||
{!compact && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-text-dark tracking-tighter">Institutional Timeline</h2>
|
||||
<p className="text-sm font-bold text-text-muted mt-1">
|
||||
Confirmed slots for <span className="text-brand-indigo">{format(selectedDate, 'MMMM d, yyyy')}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-emerald-50 text-emerald-600 rounded-full border border-emerald-100">
|
||||
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">Approved Only</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{allVenues.map((venue, idx) => {
|
||||
const venueEvents = eventsOnSelectedDate.filter(e =>
|
||||
e.location?.trim().toLowerCase() === venue.trim().toLowerCase()
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={venue}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className={cn(
|
||||
"bg-white rounded-[2.5rem] border border-slate-100 premium-shadow overflow-hidden",
|
||||
compact && "rounded-[1.5rem] premium-shadow-sm"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"p-8 border-b border-slate-50 bg-slate-50/30 flex items-center gap-4",
|
||||
compact && "p-4"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"w-12 h-12 bg-white rounded-2xl flex items-center justify-center premium-shadow-sm border border-slate-50",
|
||||
compact && "w-8 h-8 rounded-xl"
|
||||
)}>
|
||||
<MapPin className={cn("text-slate-300", compact ? "w-4 h-4" : "w-6 h-6")} />
|
||||
</div>
|
||||
<h3 className={cn("font-black text-text-dark tracking-tight", compact ? "text-sm" : "text-xl")}>{venue}</h3>
|
||||
</div>
|
||||
|
||||
<div className={cn("p-8", compact && "p-4")}>
|
||||
{venueEvents.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{venueEvents.map(event => (
|
||||
<div key={event.id} className={cn(
|
||||
"flex flex-col md:flex-row md:items-center justify-between gap-6 bg-slate-50/50 border border-slate-100 rounded-3xl hover:bg-white hover:premium-shadow transition-all group",
|
||||
compact ? "p-4" : "p-6"
|
||||
)}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn(
|
||||
"bg-white rounded-2xl flex items-center justify-center text-[10px] font-black text-brand-indigo border border-brand-indigo/10",
|
||||
compact ? "w-8 h-8 rounded-xl text-[8px]" : "w-12 h-12"
|
||||
)}>
|
||||
{event.department === 'AI&DS' ? 'AIDS' : event.department?.slice(0, 4).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className={cn("font-black text-text-dark group-hover:text-brand-indigo transition-colors", compact ? "text-xs" : "text-lg")}>{event.title}</h4>
|
||||
{!compact && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-text-muted">{event.department}</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-300">•</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-brand-indigo/60">{event.type}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center gap-4 bg-white rounded-2xl border border-slate-100 premium-shadow-sm",
|
||||
compact ? "px-3 py-2" : "px-6 py-4"
|
||||
)}>
|
||||
<Clock className={cn("text-brand-indigo", compact ? "w-3 h-3" : "w-4 h-4")} />
|
||||
<div className="flex flex-col">
|
||||
<p className="text-[8px] font-black uppercase tracking-widest text-text-muted mb-0.5">Slot</p>
|
||||
<p className={cn("font-black text-text-dark", compact ? "text-[10px]" : "text-sm")}>
|
||||
{parseDate(event.startDate) ? format(parseDate(event.startDate)!, 'hh:mm a') : 'TBD'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"flex flex-col items-center justify-center border-2 border-dashed border-slate-50 rounded-3xl",
|
||||
compact ? "py-4" : "py-12"
|
||||
)}>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.3em] text-slate-200">
|
||||
{compact ? 'Available' : 'Available for Booking'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { MapPin, Clock, Calendar as CalendarIcon, ArrowRight, Building2 } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { format, isSameDay } from 'date-fns';
|
||||
import { EventDetailsModal } from './EventDetailsModal';
|
||||
|
||||
const CORE_VENUES = [
|
||||
'GB 4th floor auditorium',
|
||||
'Wozniak Auditorium',
|
||||
'C6-02 Indoor Theatre',
|
||||
'H Block Guest Lecture Theatre',
|
||||
'Steve Jobs Computer Centre 1',
|
||||
'Steve Jobs Computer Centre 2'
|
||||
];
|
||||
|
||||
interface VenuesAtGlanceProps {
|
||||
events: any[];
|
||||
}
|
||||
|
||||
export const VenuesAtGlance: React.FC<VenuesAtGlanceProps> = ({ events }) => {
|
||||
const [selectedEvent, setSelectedEvent] = useState<any | null>(null);
|
||||
|
||||
const parseDate = (dateSource: any): Date | null => {
|
||||
if (!dateSource) return null;
|
||||
if (typeof dateSource === 'string') return new Date(dateSource);
|
||||
if (Array.isArray(dateSource)) {
|
||||
return new Date(dateSource[0], dateSource[1] - 1, dateSource[2], dateSource[3] || 0, dateSource[4] || 0);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
|
||||
// Combine core venues with any dynamically used venues in events today
|
||||
const allVenues = useMemo(() => {
|
||||
const todayEvents = events.filter(e => {
|
||||
if (e.status !== 'APPROVED' && e.status !== 'COMPLETED') return false;
|
||||
const eventDate = parseDate(e.startDate);
|
||||
if (!eventDate) return false;
|
||||
return isSameDay(eventDate, today);
|
||||
});
|
||||
|
||||
const dynamicVenues = todayEvents
|
||||
.filter(e => e.location && e.location !== 'N/A' && e.location !== 'Others.')
|
||||
.map(e => e.location.trim());
|
||||
|
||||
const finalVenues = [...CORE_VENUES];
|
||||
dynamicVenues.forEach(dv => {
|
||||
if (!finalVenues.some(cv => cv.toLowerCase() === dv.toLowerCase())) {
|
||||
finalVenues.push(dv);
|
||||
}
|
||||
});
|
||||
return finalVenues;
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="card-widget">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-brand-navy tracking-tight flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5 text-brand-indigo" />
|
||||
Venues At A Glance
|
||||
</h3>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400 mt-1">
|
||||
Today's Scheduled Events • {format(today, 'MMMM d, yyyy')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{allVenues.map((venue, idx) => {
|
||||
// Find if there is an approved/completed event for this venue today
|
||||
const todayEvents = events.filter(e => {
|
||||
if (e.status !== 'APPROVED' && e.status !== 'COMPLETED') return false;
|
||||
const eventDate = parseDate(e.startDate);
|
||||
if (!eventDate) return false;
|
||||
return isSameDay(eventDate, today) && e.location?.trim().toLowerCase() === venue.trim().toLowerCase();
|
||||
});
|
||||
|
||||
const isOccupied = todayEvents.length > 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={venue}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className={cn(
|
||||
"rounded-[1.5rem] p-5 border transition-all relative overflow-hidden",
|
||||
isOccupied
|
||||
? "bg-slate-50 border-slate-200 hover:border-brand-indigo/30 premium-shadow-sm group cursor-pointer"
|
||||
: "bg-white border-slate-100 hover:bg-slate-50 opacity-70 hover:opacity-100"
|
||||
)}
|
||||
onClick={() => isOccupied && setSelectedEvent(todayEvents[0])}
|
||||
>
|
||||
{isOccupied && (
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-brand-indigo/5 rounded-bl-[100%] transition-transform group-hover:scale-110" />
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-4 mb-4 relative z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center shrink-0 transition-colors",
|
||||
isOccupied ? "bg-white text-brand-indigo shadow-sm" : "bg-slate-50 text-slate-400"
|
||||
)}>
|
||||
<Building2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-black text-brand-navy tracking-tight line-clamp-1" title={venue}>
|
||||
{venue}
|
||||
</h4>
|
||||
<span className={cn(
|
||||
"text-[9px] font-black uppercase tracking-widest",
|
||||
isOccupied ? "text-emerald-500" : "text-slate-400"
|
||||
)}>
|
||||
{isOccupied ? 'Occupied' : 'Available'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOccupied ? (
|
||||
<div className="space-y-3 relative z-10">
|
||||
<div className="bg-white rounded-xl p-3 border border-slate-100 shadow-sm transition-transform group-hover:-translate-y-1">
|
||||
<p className="text-xs font-bold text-brand-navy line-clamp-1 mb-2">
|
||||
{todayEvents[0].title}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-text-muted">
|
||||
<Clock className="w-3.5 h-3.5 text-brand-indigo" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">
|
||||
{format(parseDate(todayEvents[0].startDate)!, 'hh:mm a')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-6 h-6 rounded-lg bg-brand-glow flex items-center justify-center text-brand-indigo group-hover:bg-brand-indigo group-hover:text-white transition-colors">
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{todayEvents.length > 1 && (
|
||||
<p className="text-[9px] font-bold text-brand-indigo/60 text-right">
|
||||
+ {todayEvents.length - 1} more today
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-16 flex items-center justify-center border border-dashed border-slate-200 rounded-xl">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-300">
|
||||
No Events
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<EventDetailsModal
|
||||
isOpen={!!selectedEvent}
|
||||
onClose={() => setSelectedEvent(null)}
|
||||
event={selectedEvent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
19
RIT-EMS-main/frontend/src/constants/clubs.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const CLUBS = [
|
||||
'General',
|
||||
'UBA',
|
||||
'YUVA Club',
|
||||
'National Service Scheme (NSS)',
|
||||
'Youth Red Cross(YRC)',
|
||||
'Rotaract Club',
|
||||
'Women Empowerment Club',
|
||||
'Techsparks Club',
|
||||
'Infinitus',
|
||||
'STEAM Club',
|
||||
'Fusion Language Club',
|
||||
'Artist League',
|
||||
'Photography Club',
|
||||
'Podcast Club',
|
||||
'Nippon Club',
|
||||
'Telgu Club',
|
||||
'Mediastic Hub'
|
||||
];
|
||||
32
RIT-EMS-main/frontend/src/constants/institutionalEvents.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface InstitutionalEvent {
|
||||
id: string;
|
||||
name: string;
|
||||
month: string;
|
||||
semester: 'ODD' | 'EVEN';
|
||||
}
|
||||
|
||||
export const INSTITUTIONAL_EVENTS: InstitutionalEvent[] = [
|
||||
// EVEN Semester (Jan - June)
|
||||
{ id: 'ie-1', name: 'New Year & Christmas Celebration', month: 'January', semester: 'EVEN' },
|
||||
{ id: 'ie-2', name: 'Republic Day Celebration', month: 'January', semester: 'EVEN' },
|
||||
{ id: 'ie-3', name: 'Pongal Celebration', month: 'January', semester: 'EVEN' },
|
||||
{ id: 'ie-4', name: 'Yatra Banner Release', month: 'January', semester: 'EVEN' },
|
||||
{ id: 'ie-5', name: 'Yatra Ethnic Day', month: 'February', semester: 'EVEN' },
|
||||
{ id: 'ie-6', name: 'Yatra', month: 'February', semester: 'EVEN' },
|
||||
{ id: 'ie-7', name: 'International Womens Day', month: 'March', semester: 'EVEN' },
|
||||
{ id: 'ie-8', name: 'Ugathi', month: 'March', semester: 'EVEN' },
|
||||
{ id: 'ie-9', name: 'Tamil New Year', month: 'April', semester: 'EVEN' },
|
||||
|
||||
// ODD Semester (July - Dec)
|
||||
{ id: 'ie-10', name: 'Techritz', month: 'July', semester: 'ODD' },
|
||||
{ id: 'ie-11', name: 'Independence Day', month: 'August', semester: 'ODD' },
|
||||
{ id: 'ie-12', name: 'AI Horizon Week', month: 'August', semester: 'ODD' },
|
||||
{ id: 'ie-13', name: 'Initiation Day', month: 'September', semester: 'ODD' },
|
||||
{ id: 'ie-14', name: 'Onam Celebration', month: 'September', semester: 'ODD' },
|
||||
{ id: 'ie-15', name: 'Teachers Day', month: 'September', semester: 'ODD' },
|
||||
{ id: 'ie-16', name: 'Engineers Day', month: 'September', semester: 'ODD' },
|
||||
{ id: 'ie-17', name: 'SDG Golu', month: 'September', semester: 'ODD' },
|
||||
{ id: 'ie-18', name: 'Ayutha Puja', month: 'October', semester: 'ODD' },
|
||||
{ id: 'ie-19', name: 'Tech Fest IIT Bombay', month: 'October', semester: 'ODD' },
|
||||
{ id: 'ie-20', name: 'EDI Conclave - FICCI Flow', month: 'November', semester: 'ODD' },
|
||||
];
|
||||
61
RIT-EMS-main/frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: 'FACULTY' | 'HOD' | 'PRINCIPAL' | 'ADMIN' | 'PLACEMENT';
|
||||
department: string;
|
||||
isClubCoordinator?: boolean;
|
||||
isPlacementStaff?: boolean;
|
||||
isClassIncharge?: boolean;
|
||||
inchargeClass?: string;
|
||||
inchargeBatch?: string;
|
||||
inchargeSection?: string;
|
||||
classStrength?: number;
|
||||
assignedClubs?: string[];
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
login: (userData: User) => void;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem('ems_user');
|
||||
if (savedUser) {
|
||||
setUser(JSON.parse(savedUser));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = (userData: User) => {
|
||||
setUser(userData);
|
||||
localStorage.setItem('ems_user', JSON.stringify(userData));
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
localStorage.removeItem('ems_user');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, isAuthenticated: !!user }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
71
RIT-EMS-main/frontend/src/index.css
Normal file
@@ -0,0 +1,71 @@
|
||||
@import "tailwindcss";
|
||||
@import "./App.css";
|
||||
|
||||
@theme {
|
||||
--color-brand-indigo: #4f46e5;
|
||||
--color-brand-navy: #0f4475;
|
||||
--color-brand-glow: rgba(79, 70, 229, 0.1);
|
||||
--color-surface-background: #f8fafc;
|
||||
--color-surface-white: #ffffff;
|
||||
--color-surface-input: #f9fafb;
|
||||
--color-text-dark: #0f172a;
|
||||
--color-text-primary: #1e293b;
|
||||
--color-text-secondary: #475569;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-status-success: #059669;
|
||||
--color-status-danger: #dc2626;
|
||||
--color-status-warning: #d97706;
|
||||
|
||||
--font-sans: "Inter", sans-serif;
|
||||
|
||||
--radius-4xl: 2rem;
|
||||
--radius-5xl: 2.5rem;
|
||||
--radius-6xl: 3.5rem;
|
||||
|
||||
--shadow-premium: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 10px 10px -5px rgba(0, 0, 0, 0.02);
|
||||
--shadow-widget: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-surface-background text-text-primary font-sans antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.glass-effect {
|
||||
@apply bg-white/80 backdrop-blur-md border border-white/20;
|
||||
}
|
||||
|
||||
.premium-shadow {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.premium-shadow-sm {
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
@apply flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 text-text-secondary font-semibold hover:bg-brand-glow hover:text-brand-indigo cursor-pointer;
|
||||
}
|
||||
|
||||
.sidebar-item-active {
|
||||
@apply bg-brand-glow text-brand-indigo;
|
||||
}
|
||||
|
||||
.card-widget {
|
||||
@apply bg-white rounded-[2rem] p-6 border border-slate-100 shadow-sm hover:shadow-md transition-shadow duration-300;
|
||||
}
|
||||
|
||||
.floating-sidebar {
|
||||
@apply fixed left-4 top-4 bottom-4 w-64 bg-white rounded-[2.5rem] border border-slate-200/60 shadow-xl z-50 flex flex-col p-6;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-slate-300 rounded-full;
|
||||
}
|
||||
3
RIT-EMS-main/frontend/src/lib/config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Dynamic API Base URL based on the current hostname
|
||||
// This allows access from localhost as well as other devices on the same network
|
||||
export const API_BASE_URL = `http://${window.location.hostname}:8081`;
|
||||
13
RIT-EMS-main/frontend/src/lib/dateUtils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const getCurrentAcademicYear = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth(); // 0-11
|
||||
|
||||
// If we are in June or later, the academic year is currentYear-(currentYear+1)
|
||||
// Otherwise, it's (currentYear-1)-currentYear
|
||||
if (month >= 5) { // June is index 5
|
||||
return `${year}-${(year + 1).toString().slice(-2)}`;
|
||||
} else {
|
||||
return `${year - 1}-${year.toString().slice(-2)}`;
|
||||
}
|
||||
};
|
||||
781
RIT-EMS-main/frontend/src/lib/firebaseBackend.ts
Normal file
@@ -0,0 +1,781 @@
|
||||
import { initializeApp, getApp, getApps } from 'firebase/app';
|
||||
import {
|
||||
getFirestore,
|
||||
collection,
|
||||
getDocs,
|
||||
doc,
|
||||
getDoc,
|
||||
setDoc,
|
||||
updateDoc,
|
||||
deleteDoc
|
||||
} from 'firebase/firestore';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyBdRUyA7LDtDReUA3TXDys71dSHgD2tOEA",
|
||||
authDomain: "ems-ritchennai1.firebaseapp.com",
|
||||
projectId: "ems-ritchennai1",
|
||||
storageBucket: "ems-ritchennai1.firebasestorage.app",
|
||||
messagingSenderId: "825363154108",
|
||||
appId: "1:825363154108:web:7b6d3430aa3b696fef3cb9",
|
||||
measurementId: "G-3W7ECQ52G1"
|
||||
};
|
||||
|
||||
// Initialize Firebase App
|
||||
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApp();
|
||||
const db = getFirestore(app);
|
||||
|
||||
// Helper: parse date strings into Date objects
|
||||
function parseDate(val: any): Date {
|
||||
if (!val) return new Date();
|
||||
let str = String(val).trim().replace(" ", "T");
|
||||
if (str.length === 10) str += "T00:00:00";
|
||||
if (str.length === 16) str += ":00";
|
||||
if (str.includes("+")) str = str.substring(0, str.indexOf("+"));
|
||||
if (str.includes("Z")) str = str.replace("Z", "");
|
||||
if (str.includes(".")) str = str.substring(0, str.indexOf("."));
|
||||
return new Date(str);
|
||||
}
|
||||
|
||||
// Helper: parse BodyInit body to JSON
|
||||
function parseBody(body: any): any {
|
||||
if (!body) return {};
|
||||
try {
|
||||
return JSON.parse(String(body));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Conflict checking logic
|
||||
async function getConflictMessage(event: any, ignoreId: any = null): Promise<string | null> {
|
||||
const start = parseDate(event.startDate || event.finalDate);
|
||||
const end = parseDate(event.endDate || (event.startDate ? new Date(parseDate(event.startDate).getTime() + 2 * 60 * 60 * 1000) : null));
|
||||
const location = String(event.location || event.venue || "").trim();
|
||||
const institution = String(event.institution || "RIT").trim();
|
||||
|
||||
if (!location || !start || !end) return null;
|
||||
|
||||
try {
|
||||
const colRef = collection(db, 'ems_events');
|
||||
const snap = await getDocs(colRef);
|
||||
const existingEvents = snap.docs.map(d => d.data());
|
||||
|
||||
for (const other of existingEvents) {
|
||||
if (other.status === 'CANCELLED') continue;
|
||||
if (ignoreId && String(other.id) === String(ignoreId)) continue;
|
||||
if (event.id && String(other.id) === String(event.id)) continue;
|
||||
if (event.groupRequestId && other.groupRequestId === event.groupRequestId) continue;
|
||||
|
||||
const oStart = parseDate(other.startDate);
|
||||
const oEnd = parseDate(other.endDate);
|
||||
const oLoc = String(other.location || "").trim();
|
||||
const oInst = String(other.institution || "RIT").trim();
|
||||
|
||||
// Check overlap
|
||||
if (
|
||||
oInst === institution &&
|
||||
oLoc.toLowerCase() === location.toLowerCase() &&
|
||||
start < oEnd &&
|
||||
end > oStart
|
||||
) {
|
||||
const timeFormatter = new Intl.DateTimeFormat('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
return `Venue Conflict: '${location}' is already booked for '${other.title}' from ${timeFormatter.format(oStart)} to ${timeFormatter.format(oEnd)}`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Conflict checking error:", err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate sequential IDs
|
||||
function generateNumericId(): number {
|
||||
return Date.now() + Math.floor(Math.random() * 1000);
|
||||
}
|
||||
|
||||
// Seeding logic
|
||||
async function seedDatabaseIfEmpty() {
|
||||
try {
|
||||
const usersSnap = await getDocs(collection(db, 'ems_users'));
|
||||
if (usersSnap.empty) {
|
||||
console.log("[Firebase Backend] Seeding users...");
|
||||
const defaultUsers = [
|
||||
{ id: 1, email: "admin@rit.edu", password: "admin123", fullName: "System Administrator", role: "ADMIN", department: "ADMIN", isClubCoordinator: false, isPlacementStaff: false, isClassIncharge: false, assignedClubs: [] },
|
||||
{ id: 2, email: "faculty@rit.edu", password: "faculty123", fullName: "Dr. Faculty Member", role: "FACULTY", department: "CSE", isClubCoordinator: false, isPlacementStaff: false, isClassIncharge: true, inchargeClass: "CSE", inchargeBatch: "3rd Year", inchargeSection: "A", classStrength: 60, assignedClubs: [] },
|
||||
{ id: 3, email: "hod@rit.edu", password: "hod123", fullName: "Prof. Head of Dept", role: "HOD", department: "AI&ML", isClubCoordinator: false, isPlacementStaff: false, isClassIncharge: false, assignedClubs: [] },
|
||||
{ id: 4, email: "principal@rit.edu", password: "principal123", fullName: "Dr. College Principal", role: "PRINCIPAL", department: "ADMIN", isClubCoordinator: false, isPlacementStaff: false, isClassIncharge: false, assignedClubs: [] },
|
||||
{ id: 5, email: "placement@rit.edu", password: "placement123", fullName: "Placement Coordinator", role: "PLACEMENT", department: "Placement Department", isClubCoordinator: false, isPlacementStaff: true, isClassIncharge: false, assignedClubs: [] },
|
||||
{ id: 6, email: "admin2@rit.edu", password: "admin123", fullName: "Secondary Admin", role: "ADMIN", department: "ADMIN", isClubCoordinator: false, isPlacementStaff: false, isClassIncharge: false, assignedClubs: [] },
|
||||
{ id: 7, email: "principal2@rit.edu", password: "principal123", fullName: "Vice Principal", role: "PRINCIPAL", department: "ADMIN", isClubCoordinator: false, isPlacementStaff: false, isClassIncharge: false, assignedClubs: [] },
|
||||
{ id: 8, email: "hod_cse@rit.edu", password: "hod123", fullName: "CSE HOD", role: "HOD", department: "CSE", isClubCoordinator: false, isPlacementStaff: false, isClassIncharge: false, assignedClubs: [] },
|
||||
{ id: 9, email: "faculty2@rit.edu", password: "faculty123", fullName: "Assistant Professor CSE", role: "FACULTY", department: "CSE", isClubCoordinator: false, isPlacementStaff: false, isClassIncharge: false, assignedClubs: [] }
|
||||
];
|
||||
|
||||
for (const u of defaultUsers) {
|
||||
await setDoc(doc(db, 'ems_users', String(u.id)), u);
|
||||
}
|
||||
}
|
||||
|
||||
const classesSnap = await getDocs(collection(db, 'ems_classes'));
|
||||
if (classesSnap.empty) {
|
||||
console.log("[Firebase Backend] Seeding classes...");
|
||||
const defaultClasses = [
|
||||
{ id: 1, institution: "RIT", department: "CSE", academicYear: "3rd Year", sections: ["A", "B"], status: "Ready" },
|
||||
{ id: 2, institution: "RIT", department: "AI&ML", academicYear: "3rd Year", sections: ["A"], status: "Ready" },
|
||||
{ id: 3, institution: "RIT", department: "ECE", academicYear: "2nd Year", sections: ["A", "B", "C"], status: "Ready" }
|
||||
];
|
||||
for (const c of defaultClasses) {
|
||||
await setDoc(doc(db, 'ems_classes', String(c.id)), c);
|
||||
}
|
||||
}
|
||||
|
||||
const eventsSnap = await getDocs(collection(db, 'ems_events'));
|
||||
if (eventsSnap.empty) {
|
||||
console.log("[Firebase Backend] Seeding events...");
|
||||
const venues = [
|
||||
"GB 4th floor auditorium",
|
||||
"Wozniak Auditorium",
|
||||
"C6-02 Indoor Theatre",
|
||||
"H Block Guest Lecture Theatre"
|
||||
];
|
||||
const categories = ["ACADEMIC", "CLUB", "PLACEMENT", "SPORTS"];
|
||||
const types = ["Workshop", "Seminar", "Guest Lecture", "Competition"];
|
||||
const statuses = ["REQUESTED", "APPROVED", "COMPLETED"];
|
||||
|
||||
let evId = 100;
|
||||
const now = new Date();
|
||||
|
||||
for (let month = now.getMonth() - 2; month <= now.getMonth() + 2; month++) {
|
||||
const targetMonth = (month + 12) % 12;
|
||||
const targetYear = now.getFullYear() + (month < 0 ? -1 : (month > 11 ? 1 : 0));
|
||||
|
||||
for (const dept of ["CSE", "AI&ML"]) {
|
||||
const start = new Date(targetYear, targetMonth, 15, 10, 0);
|
||||
const end = new Date(targetYear, targetMonth, 15, 13, 0);
|
||||
const venue = venues[Math.floor(Math.random() * venues.length)];
|
||||
const cat = categories[Math.floor(Math.random() * categories.length)];
|
||||
const type = types[Math.floor(Math.random() * types.length)];
|
||||
const status = statuses[Math.floor(Math.random() * statuses.length)];
|
||||
|
||||
const event = {
|
||||
id: evId++,
|
||||
title: `${dept} ${type} - ${targetMonth + 1}/15`,
|
||||
description: `Sample event for ${dept} department.`,
|
||||
startDate: start.toISOString(),
|
||||
endDate: end.toISOString(),
|
||||
location: venue,
|
||||
category: cat,
|
||||
type: type,
|
||||
institution: "RIT",
|
||||
department: dept,
|
||||
academicYears: ["3rd Year"],
|
||||
status: status,
|
||||
budget: 5000,
|
||||
hasRegistrationFee: false,
|
||||
registrationFee: 0,
|
||||
proposer: { id: 2, fullName: "Dr. Faculty Member", email: "faculty@rit.edu", role: "FACULTY" }
|
||||
};
|
||||
await setDoc(doc(db, 'ems_events', String(event.id)), event);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Seeding failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Perform initial seed
|
||||
seedDatabaseIfEmpty();
|
||||
|
||||
// Set up the fetch interceptor
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const urlStr = typeof input === 'string' ? input : (input instanceof URL ? input.href : input.url);
|
||||
|
||||
// Only intercept endpoints containing '/api/'
|
||||
if (!urlStr.includes('/api/')) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(urlStr, window.location.origin);
|
||||
const path = parsedUrl.pathname;
|
||||
const method = (init?.method || 'GET').toUpperCase();
|
||||
const queryParams = parsedUrl.searchParams;
|
||||
|
||||
console.log(`[Firebase Interceptor] ${method} ${path}`);
|
||||
|
||||
// Helper response builder
|
||||
const jsonResponse = (data: any, status = 200) => {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
// ----------------------------------------------------
|
||||
// AUTH CONTROLLER
|
||||
// ----------------------------------------------------
|
||||
if (path === '/api/auth/login' && method === 'POST') {
|
||||
const { email, password } = parseBody(init?.body);
|
||||
const snap = await getDocs(collection(db, 'ems_users'));
|
||||
const userDoc = snap.docs.find(d => d.data().email?.toLowerCase() === email?.toLowerCase());
|
||||
|
||||
if (userDoc) {
|
||||
const u = userDoc.data();
|
||||
if (u.password === password) {
|
||||
// Exclude password from response
|
||||
const { password: _, ...userData } = u;
|
||||
return jsonResponse({
|
||||
...userData,
|
||||
department: userData.department || "N/A",
|
||||
assignedClubs: userData.assignedClubs || []
|
||||
});
|
||||
}
|
||||
}
|
||||
return jsonResponse({ message: "Invalid email or passcode" }, 401);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// ADMIN USER CONTROLLER
|
||||
// ----------------------------------------------------
|
||||
if (path === '/api/admin/users' && method === 'GET') {
|
||||
const snap = await getDocs(collection(db, 'ems_users'));
|
||||
return jsonResponse(snap.docs.map(d => d.data()));
|
||||
}
|
||||
|
||||
if (path === '/api/admin/users' && method === 'POST') {
|
||||
const payload = parseBody(init?.body);
|
||||
const snap = await getDocs(collection(db, 'ems_users'));
|
||||
|
||||
if (snap.docs.some(d => d.data().email?.toLowerCase() === payload.email?.toLowerCase())) {
|
||||
return jsonResponse({ message: "User with this email already exists" }, 400);
|
||||
}
|
||||
|
||||
const newUser = {
|
||||
...payload,
|
||||
id: generateNumericId(),
|
||||
email: payload.email?.trim().toLowerCase(),
|
||||
fullName: payload.fullName?.trim(),
|
||||
role: payload.role || 'FACULTY',
|
||||
department: payload.department || 'H&S Dept',
|
||||
assignedClubs: payload.assignedClubs || []
|
||||
};
|
||||
|
||||
await setDoc(doc(db, 'ems_users', String(newUser.id)), newUser);
|
||||
return jsonResponse(newUser);
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/admin/users/') && method === 'PUT') {
|
||||
const parts = path.split('/');
|
||||
const id = parts[parts.length - 1];
|
||||
const payload = parseBody(init?.body);
|
||||
const docRef = doc(db, 'ems_users', String(id));
|
||||
const snap = await getDoc(docRef);
|
||||
|
||||
if (!snap.exists()) {
|
||||
return jsonResponse({ message: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...snap.data(),
|
||||
...payload,
|
||||
id: snap.data().id // lock ID
|
||||
};
|
||||
|
||||
await setDoc(docRef, updated);
|
||||
return jsonResponse(updated);
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/admin/users/') && method === 'DELETE') {
|
||||
const parts = path.split('/');
|
||||
const id = parts[parts.length - 1];
|
||||
await deleteDoc(doc(db, 'ems_users', String(id)));
|
||||
return jsonResponse({ message: "User deleted successfully" });
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// CLASSES CONTROLLER
|
||||
// ----------------------------------------------------
|
||||
if (path === '/api/classes' && method === 'GET') {
|
||||
const snap = await getDocs(collection(db, 'ems_classes'));
|
||||
return jsonResponse(snap.docs.map(d => d.data()));
|
||||
}
|
||||
|
||||
if (path === '/api/classes' && method === 'POST') {
|
||||
const payload = parseBody(init?.body);
|
||||
const newClass = {
|
||||
...payload,
|
||||
id: generateNumericId(),
|
||||
status: payload.status || "Ready"
|
||||
};
|
||||
await setDoc(doc(db, 'ems_classes', String(newClass.id)), newClass);
|
||||
return jsonResponse(newClass);
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/classes/') && method === 'DELETE') {
|
||||
const parts = path.split('/');
|
||||
const id = parts[parts.length - 1];
|
||||
await deleteDoc(doc(db, 'ems_classes', String(id)));
|
||||
return jsonResponse({ message: "Class mapping deleted successfully" });
|
||||
}
|
||||
|
||||
if (path === '/api/classes/promote' && method === 'POST') {
|
||||
const inst = queryParams.get('institution') || '';
|
||||
const snap = await getDocs(collection(db, 'ems_classes'));
|
||||
const classes = snap.docs.map(d => d.data());
|
||||
|
||||
for (const cm of classes) {
|
||||
if (cm.institution?.trim().toLowerCase() !== inst.trim().toLowerCase()) {
|
||||
continue;
|
||||
}
|
||||
const ref = doc(db, 'ems_classes', String(cm.id));
|
||||
switch (cm.academicYear) {
|
||||
case "1st Year":
|
||||
await updateDoc(ref, { academicYear: "2nd Year" });
|
||||
break;
|
||||
case "2nd Year":
|
||||
await updateDoc(ref, { academicYear: "3rd Year" });
|
||||
break;
|
||||
case "3rd Year":
|
||||
await updateDoc(ref, { academicYear: "4th Year" });
|
||||
break;
|
||||
case "4th Year":
|
||||
await deleteDoc(ref);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return jsonResponse({ message: `Academic year promotion completed for ${inst}` });
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// NOTES CONTROLLER
|
||||
// ----------------------------------------------------
|
||||
if (path === '/api/notes' && method === 'GET') {
|
||||
const snap = await getDocs(collection(db, 'ems_notes'));
|
||||
const list = snap.docs.map(d => d.data());
|
||||
// Sort desc
|
||||
list.sort((a, b) => new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime());
|
||||
return jsonResponse(list);
|
||||
}
|
||||
|
||||
if (path === '/api/notes' && method === 'POST') {
|
||||
const payload = parseBody(init?.body);
|
||||
const newNote = {
|
||||
...payload,
|
||||
id: generateNumericId(),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
await setDoc(doc(db, 'ems_notes', String(newNote.id)), newNote);
|
||||
return jsonResponse(newNote);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// EVENTS CONTROLLER
|
||||
// ----------------------------------------------------
|
||||
if (path === '/api/events' && method === 'GET') {
|
||||
const snap = await getDocs(collection(db, 'ems_events'));
|
||||
const events = snap.docs.map(d => d.data());
|
||||
|
||||
// Populate conflicts
|
||||
for (const event of events) {
|
||||
if (!['APPROVED', 'COMPLETED', 'CANCELLED'].includes(event.status)) {
|
||||
const conflictMsg = await getConflictMessage(event);
|
||||
if (conflictMsg) {
|
||||
event.conflictMessage = conflictMsg;
|
||||
}
|
||||
}
|
||||
}
|
||||
return jsonResponse(events);
|
||||
}
|
||||
|
||||
if (path === '/api/events/propose' && method === 'POST') {
|
||||
const payload = parseBody(init?.body);
|
||||
const userId = payload.userId;
|
||||
const userSnap = await getDoc(doc(db, 'ems_users', String(userId)));
|
||||
|
||||
if (!userSnap.exists()) {
|
||||
return jsonResponse({ message: "Proposer user not found" }, 400);
|
||||
}
|
||||
|
||||
const proposer = userSnap.data();
|
||||
const start = parseDate(payload.startDate);
|
||||
const end = parseDate(payload.endDate);
|
||||
|
||||
if (start < new Date()) {
|
||||
return jsonResponse({ message: "Cannot schedule events in the past" }, 400);
|
||||
}
|
||||
|
||||
if (end <= start) {
|
||||
return jsonResponse({ message: "End date must be after start date" }, 400);
|
||||
}
|
||||
|
||||
const conflictMsg = await getConflictMessage(payload);
|
||||
if (conflictMsg) {
|
||||
// If placement with cancelConflicting, we cancel other events
|
||||
if (payload.category === 'PLACEMENT' && payload.cancelConflicting) {
|
||||
const colRef = collection(db, 'ems_events');
|
||||
const snap = await getDocs(colRef);
|
||||
const allEvs = snap.docs.map(d => d.data());
|
||||
|
||||
for (const other of allEvs) {
|
||||
if (other.status === 'CANCELLED') continue;
|
||||
if (payload.groupRequestId && other.groupRequestId === payload.groupRequestId) continue;
|
||||
|
||||
const oStart = parseDate(other.startDate);
|
||||
const oEnd = parseDate(other.endDate);
|
||||
const oLoc = String(other.location || "").trim();
|
||||
const oInst = String(other.institution || "RIT").trim();
|
||||
|
||||
if (
|
||||
oInst === String(payload.institution || "RIT").trim() &&
|
||||
oLoc.toLowerCase() === String(payload.venue || "").trim().toLowerCase() &&
|
||||
start < oEnd &&
|
||||
end > oStart
|
||||
) {
|
||||
await updateDoc(doc(db, 'ems_events', String(other.id)), {
|
||||
status: 'CANCELLED',
|
||||
rejectionReason: "This event is cancelled due to the placement activity happening at the venue at this timing."
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Find overlapping
|
||||
const colRef = collection(db, 'ems_events');
|
||||
const snap = await getDocs(colRef);
|
||||
const list = snap.docs.map(d => d.data()).filter(other => {
|
||||
if (other.status === 'CANCELLED') return false;
|
||||
const oStart = parseDate(other.startDate);
|
||||
const oEnd = parseDate(other.endDate);
|
||||
const oLoc = String(other.location || "").trim();
|
||||
const oInst = String(other.institution || "RIT").trim();
|
||||
return (
|
||||
oInst === String(payload.institution || "RIT").trim() &&
|
||||
oLoc.toLowerCase() === String(payload.venue || "").trim().toLowerCase() &&
|
||||
start < oEnd &&
|
||||
end > oStart
|
||||
);
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
message: conflictMsg,
|
||||
conflicts: list,
|
||||
canOverride: payload.category === 'PLACEMENT'
|
||||
}, 409);
|
||||
}
|
||||
}
|
||||
|
||||
// Propose logic status
|
||||
let status = "REQUESTED";
|
||||
if (proposer.role === 'PRINCIPAL') {
|
||||
status = "APPROVED";
|
||||
} else if (
|
||||
proposer.role === 'HOD' ||
|
||||
payload.category === 'CLUB' ||
|
||||
payload.category === 'PLACEMENT' ||
|
||||
payload.category === 'INSTITUTIONAL' ||
|
||||
proposer.role === 'PLACEMENT' ||
|
||||
proposer.isPlacementStaff ||
|
||||
proposer.department === 'Placement Department'
|
||||
) {
|
||||
status = "PENDING_PR";
|
||||
}
|
||||
|
||||
const budgetStr = payload.budget ? String(payload.budget) : "";
|
||||
const feeStr = payload.registrationFee ? String(payload.registrationFee) : "";
|
||||
|
||||
const event = {
|
||||
id: generateNumericId(),
|
||||
title: payload.eventName || "Untitled Event",
|
||||
description: payload.description || "",
|
||||
startDate: start.toISOString(),
|
||||
endDate: end.toISOString(),
|
||||
location: String(payload.venue || "TBD").trim(),
|
||||
category: payload.category || "ACADEMIC",
|
||||
type: payload.eventType || "Institutional",
|
||||
institution: String(payload.institution || "RIT").trim(),
|
||||
department: payload.department || "General",
|
||||
academicYears: payload.academicYears || [],
|
||||
status: status,
|
||||
guestName: payload.guestName || null,
|
||||
guestSocialProfile: payload.socialProfile || null,
|
||||
requirements: payload.requirements || [],
|
||||
targetedSections: payload.targetedSections || [],
|
||||
groupRequestId: payload.groupRequestId || null,
|
||||
sponsors: payload.sponsors || [],
|
||||
budget: budgetStr === "" ? 0.0 : parseFloat(budgetStr),
|
||||
hasRegistrationFee: !!payload.hasRegistrationFee,
|
||||
registrationFee: feeStr === "" ? 0.0 : parseFloat(feeStr),
|
||||
centreName: payload.centreName || null,
|
||||
isPublicEvent: !!payload.isPublicEvent,
|
||||
proposer: {
|
||||
id: proposer.id,
|
||||
fullName: proposer.fullName,
|
||||
email: proposer.email,
|
||||
role: proposer.role
|
||||
}
|
||||
};
|
||||
|
||||
await setDoc(doc(db, 'ems_events', String(event.id)), event);
|
||||
return jsonResponse({ message: "Event proposed successfully", id: event.id });
|
||||
}
|
||||
|
||||
if (path === '/api/events/batch-create' && method === 'POST') {
|
||||
const batchList = parseBody(init?.body);
|
||||
const eventsToSave: any[] = [];
|
||||
|
||||
for (const payload of batchList) {
|
||||
const title = payload.title || "Untitled Event";
|
||||
const dateObj = payload.startDate || payload.finalDate;
|
||||
if (!dateObj) {
|
||||
return jsonResponse({ message: `Missing date for event: ${title}` }, 400);
|
||||
}
|
||||
|
||||
const start = parseDate(dateObj);
|
||||
let end;
|
||||
if (payload.startDate && payload.endDate) {
|
||||
try {
|
||||
const origStart = parseDate(payload.startDate);
|
||||
const origEnd = parseDate(payload.endDate);
|
||||
const durationMs = origEnd.getTime() - origStart.getTime();
|
||||
end = new Date(start.getTime() + durationMs);
|
||||
} catch {
|
||||
end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
|
||||
}
|
||||
} else {
|
||||
end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
if (end <= start) {
|
||||
return jsonResponse({ message: `End date must be after start date for event: ${title}` }, 400);
|
||||
}
|
||||
|
||||
const dept = payload.targetDepartment || payload.department || "General";
|
||||
const ay = payload.targetBatch ? [payload.targetBatch] : (payload.academicYears || []);
|
||||
const venue = payload.venue || payload.location || "TBD";
|
||||
|
||||
const proposer = payload.proposer || { id: 1, fullName: "Admin", email: "admin@rit.edu", role: "ADMIN" };
|
||||
|
||||
const event = {
|
||||
id: generateNumericId(),
|
||||
title,
|
||||
description: payload.description || "",
|
||||
startDate: start.toISOString(),
|
||||
endDate: end.toISOString(),
|
||||
location: String(venue).trim(),
|
||||
category: payload.category || "ACADEMIC",
|
||||
type: payload.type || "Seminar",
|
||||
institution: String(payload.institution || "RIT").trim(),
|
||||
department: dept,
|
||||
academicYears: ay,
|
||||
status: payload.status || "APPROVED",
|
||||
requirements: payload.requirements || [],
|
||||
proposer
|
||||
};
|
||||
|
||||
const conflictMsg = await getConflictMessage(event);
|
||||
if (conflictMsg) {
|
||||
return jsonResponse({ message: `Conflict in batch item '${title}': ${conflictMsg}` }, 400);
|
||||
}
|
||||
|
||||
eventsToSave.push(event);
|
||||
}
|
||||
|
||||
// Check intra-batch conflicts
|
||||
for (let i = 0; i < eventsToSave.length; i++) {
|
||||
const current = eventsToSave[i];
|
||||
const currentStart = parseDate(current.startDate);
|
||||
const currentEnd = parseDate(current.endDate);
|
||||
const currentLoc = current.location.toLowerCase();
|
||||
|
||||
for (let j = i + 1; j < eventsToSave.length; j++) {
|
||||
const other = eventsToSave[j];
|
||||
const otherStart = parseDate(other.startDate);
|
||||
const otherEnd = parseDate(other.endDate);
|
||||
const otherLoc = other.location.toLowerCase();
|
||||
|
||||
if (
|
||||
current.institution === other.institution &&
|
||||
currentLoc === otherLoc &&
|
||||
currentStart < otherEnd &&
|
||||
currentEnd > otherStart
|
||||
) {
|
||||
return jsonResponse({ message: `Conflict between imported events '${current.title}' and '${other.title}' at ${current.location}` }, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const ev of eventsToSave) {
|
||||
await setDoc(doc(db, 'ems_events', String(ev.id)), ev);
|
||||
}
|
||||
return jsonResponse({ message: "Batch events created successfully", count: eventsToSave.length });
|
||||
}
|
||||
|
||||
if (path.endsWith('/approve') && method === 'POST') {
|
||||
const parts = path.split('/');
|
||||
const id = parts[parts.length - 2];
|
||||
const userId = queryParams.get('userId');
|
||||
|
||||
const userSnap = await getDoc(doc(db, 'ems_users', String(userId)));
|
||||
const eventRef = doc(db, 'ems_events', String(id));
|
||||
const eventSnap = await getDoc(eventRef);
|
||||
|
||||
if (!userSnap.exists() || !eventSnap.exists()) {
|
||||
return jsonResponse({ message: "User or Event not found" }, 400);
|
||||
}
|
||||
|
||||
const user = userSnap.data();
|
||||
const event = eventSnap.data();
|
||||
|
||||
const conflictMsg = await getConflictMessage(event, id);
|
||||
if (conflictMsg) {
|
||||
return jsonResponse({ message: conflictMsg }, 409);
|
||||
}
|
||||
|
||||
let newStatus = event.status;
|
||||
if (user.role === 'HOD') {
|
||||
newStatus = 'PENDING_PR';
|
||||
} else if (user.role === 'PRINCIPAL') {
|
||||
newStatus = 'APPROVED';
|
||||
} else {
|
||||
return jsonResponse({ message: "Only HoD or Principal can approve events" }, 403);
|
||||
}
|
||||
|
||||
await updateDoc(eventRef, { status: newStatus });
|
||||
return jsonResponse({ message: "Event action completed successfully", status: newStatus });
|
||||
}
|
||||
|
||||
if (path.endsWith('/reject') && method === 'POST') {
|
||||
const parts = path.split('/');
|
||||
const id = parts[parts.length - 2];
|
||||
const userId = queryParams.get('userId');
|
||||
const { reason } = parseBody(init?.body);
|
||||
|
||||
if (!reason || reason.trim() === '') {
|
||||
return jsonResponse({ message: "Rejection reason is mandatory" }, 400);
|
||||
}
|
||||
|
||||
const userSnap = await getDoc(doc(db, 'ems_users', String(userId)));
|
||||
const eventRef = doc(db, 'ems_events', String(id));
|
||||
const eventSnap = await getDoc(eventRef);
|
||||
|
||||
if (!userSnap.exists() || !eventSnap.exists()) {
|
||||
return jsonResponse({ message: "User or Event not found" }, 400);
|
||||
}
|
||||
|
||||
const user = userSnap.data();
|
||||
let newStatus = eventSnap.data().status;
|
||||
|
||||
if (user.role === 'HOD') {
|
||||
newStatus = 'HOD_REJECTED';
|
||||
} else if (user.role === 'PRINCIPAL') {
|
||||
newStatus = 'PRINCIPAL_REJECTED';
|
||||
} else {
|
||||
return jsonResponse({ message: "Only HoD or Principal can reject events" }, 403);
|
||||
}
|
||||
|
||||
await updateDoc(eventRef, { status: newStatus, rejectionReason: reason });
|
||||
return jsonResponse({ message: "Event rejected successfully", status: newStatus });
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/events/') && method === 'PUT') {
|
||||
const parts = path.split('/');
|
||||
const id = parts[parts.length - 1];
|
||||
const payload = parseBody(init?.body);
|
||||
|
||||
const eventRef = doc(db, 'ems_events', String(id));
|
||||
const eventSnap = await getDoc(eventRef);
|
||||
|
||||
if (!eventSnap.exists()) {
|
||||
return jsonResponse({ message: "Event not found" }, 404);
|
||||
}
|
||||
|
||||
const event = eventSnap.data();
|
||||
const userId = payload.userId;
|
||||
const userSnap = await getDoc(doc(db, 'ems_users', String(userId)));
|
||||
|
||||
if (!userSnap.exists()) {
|
||||
return jsonResponse({ message: "User not found" }, 400);
|
||||
}
|
||||
|
||||
const user = userSnap.data();
|
||||
const isAdmin = user.role === 'ADMIN';
|
||||
|
||||
if (!isAdmin) {
|
||||
if (String(event.proposer?.id) !== String(userId)) {
|
||||
return jsonResponse({ message: "Only the proposer can edit this event" }, 403);
|
||||
}
|
||||
if (event.status !== 'REQUESTED') {
|
||||
return jsonResponse({ message: "Event cannot be edited once it moves past the initial request stage" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
const start = payload.startDate ? parseDate(payload.startDate) : parseDate(event.startDate);
|
||||
const end = payload.endDate ? parseDate(payload.endDate) : parseDate(event.endDate);
|
||||
|
||||
if (end <= start) {
|
||||
return jsonResponse({ message: "End date must be after start date" }, 400);
|
||||
}
|
||||
|
||||
const testEvent = {
|
||||
...event,
|
||||
...payload,
|
||||
startDate: start.toISOString(),
|
||||
endDate: end.toISOString()
|
||||
};
|
||||
|
||||
const conflictMsg = await getConflictMessage(testEvent, id);
|
||||
if (conflictMsg) {
|
||||
return jsonResponse({ message: conflictMsg }, 409);
|
||||
}
|
||||
|
||||
const updatedFields: any = {};
|
||||
if (payload.title !== undefined) updatedFields.title = payload.title;
|
||||
if (payload.startDate !== undefined) updatedFields.startDate = start.toISOString();
|
||||
if (payload.endDate !== undefined) updatedFields.endDate = end.toISOString();
|
||||
if (payload.eventType !== undefined) updatedFields.type = payload.eventType;
|
||||
if (payload.institution !== undefined) updatedFields.institution = payload.institution;
|
||||
if (payload.department !== undefined) updatedFields.department = payload.department;
|
||||
if (payload.venue !== undefined) updatedFields.location = payload.venue;
|
||||
if (payload.guestName !== undefined) updatedFields.guestName = payload.guestName;
|
||||
if (payload.socialProfile !== undefined) updatedFields.guestSocialProfile = payload.socialProfile;
|
||||
if (payload.academicYears !== undefined) updatedFields.academicYears = payload.academicYears;
|
||||
if (payload.targetedSections !== undefined) updatedFields.targetedSections = payload.targetedSections;
|
||||
if (payload.requirements !== undefined) updatedFields.requirements = payload.requirements;
|
||||
if (payload.sponsors !== undefined) updatedFields.sponsors = payload.sponsors;
|
||||
if (payload.description !== undefined) updatedFields.description = payload.description;
|
||||
if (payload.budget !== undefined) {
|
||||
const b = String(payload.budget);
|
||||
updatedFields.budget = b === "" ? 0.0 : parseFloat(b);
|
||||
}
|
||||
if (payload.hasRegistrationFee !== undefined) updatedFields.hasRegistrationFee = !!payload.hasRegistrationFee;
|
||||
if (payload.registrationFee !== undefined) {
|
||||
const f = String(payload.registrationFee);
|
||||
updatedFields.registrationFee = f === "" ? 0.0 : parseFloat(f);
|
||||
}
|
||||
if (payload.centreName !== undefined) updatedFields.centreName = payload.centreName;
|
||||
if (payload.isPublicEvent !== undefined) updatedFields.isPublicEvent = !!payload.isPublicEvent;
|
||||
if (payload.status !== undefined && isAdmin) {
|
||||
updatedFields.status = payload.status;
|
||||
}
|
||||
|
||||
await updateDoc(eventRef, updatedFields);
|
||||
return jsonResponse({ message: "Event updated successfully" });
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/events/') && method === 'DELETE') {
|
||||
const parts = path.split('/');
|
||||
const id = parts[parts.length - 1];
|
||||
await deleteDoc(doc(db, 'ems_events', String(id)));
|
||||
return jsonResponse({ message: "Event deleted successfully" });
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`Interceptor routing error:`, err);
|
||||
return jsonResponse({ message: "Internal server error: " + err.message }, 500);
|
||||
}
|
||||
|
||||
// Fallback to original fetch
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
console.log("[Firebase Backend] Fetch interceptor active.");
|
||||
6
RIT-EMS-main/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
11
RIT-EMS-main/frontend/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import './lib/firebaseBackend'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
147
RIT-EMS-main/frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { API_BASE_URL } from '../lib/config';
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { User, Lock, Eye, EyeOff, ArrowRight } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { cn } from '../lib/utils';
|
||||
import ritLogo from '../assets/images/college-logo.png';
|
||||
import ritBuilding from '../assets/images/ritchennai.jpg';
|
||||
|
||||
export const LoginPage: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
login(data);
|
||||
} else {
|
||||
const errData = await response.json();
|
||||
setError(errData.message || 'Login failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Connection failed. Please ensure the backend is running.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
|
||||
<div className="max-w-6xl w-full flex flex-col md:flex-row bg-white rounded-[2.5rem] overflow-hidden premium-shadow min-h-[700px]">
|
||||
{/* Left Side: Login Form */}
|
||||
<div className="w-full md:w-[45%] p-10 md:p-16 flex flex-col">
|
||||
<div className="mb-12">
|
||||
<img src={ritLogo} alt="RIT Logo" className="h-10 mb-8 object-contain" />
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-brand-indigo mb-2">
|
||||
RAJALAKSHMI INSTITUTE OF TECHNOLOGY
|
||||
</p>
|
||||
<h1 className="text-4xl font-black text-text-dark tracking-tight mb-2">
|
||||
RIT EMS
|
||||
</h1>
|
||||
<p className="text-text-muted font-medium">
|
||||
Event Management System
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-[10px] font-black uppercase tracking-widest text-text-muted mb-2">
|
||||
Institutional Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-muted" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Username or email"
|
||||
className="w-full bg-slate-50 border border-transparent rounded-2xl py-4 pl-12 pr-4 text-sm font-semibold focus:outline-none focus:bg-white focus:border-brand-indigo/30 transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="block text-[10px] font-black uppercase tracking-widest text-text-muted">
|
||||
Passcode
|
||||
</label>
|
||||
<button type="button" className="text-[10px] font-black uppercase tracking-widest text-brand-indigo hover:opacity-70 transition-all">
|
||||
Forgot?
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-muted" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full bg-slate-50 border border-transparent rounded-2xl py-4 pl-12 pr-12 text-sm font-semibold focus:outline-none focus:bg-white focus:border-brand-indigo/30 transition-all"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-text-muted hover:text-brand-indigo"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-status-danger text-xs font-bold bg-red-50 p-3 rounded-xl border border-red-100 animate-shake">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"w-full bg-brand-navy text-white rounded-2xl py-4 flex items-center justify-center gap-3 font-black text-[11px] uppercase tracking-widest transition-all hover:scale-[1.01] active:scale-[0.99] premium-shadow",
|
||||
isLoading && "opacity-70 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isLoading ? 'Processing...' : 'Enter Dashboard'}
|
||||
{!isLoading && <ArrowRight className="w-4 h-4" />}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Building Image */}
|
||||
<div className="hidden md:block flex-1 relative">
|
||||
<img
|
||||
src={ritBuilding}
|
||||
alt="College Building"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
<div className="absolute bottom-10 left-10 right-10">
|
||||
<div className="bg-white/10 backdrop-blur-md border border-white/20 p-6 rounded-[2rem] inline-block">
|
||||
<p className="text-white font-bold text-lg">Rajalakshmi Institute of Technology</p>
|
||||
<p className="text-white/70 text-sm font-medium">Nurturing Excellence since 2008</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
28
RIT-EMS-main/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"strictNullChecks": false,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
RIT-EMS-main/frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
RIT-EMS-main/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
RIT-EMS-main/frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||