Convert backends to Firebase and combine projects

This commit is contained in:
2026-06-18 14:07:24 +05:30
commit 0a76feafc5
147 changed files with 35104 additions and 0 deletions

24
RIT-EMS-main/frontend/.gitignore vendored Normal file
View 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?

View 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...
},
},
])
```

View 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,
},
},
])

View 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,,,,
1 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
2 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.
3 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
4 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
5 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
6 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

View 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))

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View 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

View 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);
}
}

View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);

File diff suppressed because it is too large Load Diff

View 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>
);
};

File diff suppressed because it is too large Load Diff

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View 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'
];

View 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' },
];

View 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;
};

View 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;
}

View 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`;

View 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)}`;
}
};

View 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.");

View 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));
}

View 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>,
)

View 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>
);
};

View 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"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})