code base
This commit is contained in:
2
backend/.gitignore
vendored
Normal file
2
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/node_modules
|
||||||
|
.env
|
||||||
1518
backend/package-lock.json
generated
Normal file
1518
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
backend/package.json
Normal file
31
backend/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.20.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nodemailer": "^6.9.16",
|
||||||
|
"prisma": "^5.20.0"
|
||||||
|
},
|
||||||
|
"name": "backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test2": "nodemon --inspect src/server.js",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"dev": "nodemon src/server.js",
|
||||||
|
"start": "node src/server.js"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
101
backend/prisma/schema.prisma
Normal file
101
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// Generator to create Prisma Client
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["native", "darwin-arm64", "linux-musl-arm64-openssl-3.0.x"]
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
relationMode = "prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Institute {
|
||||||
|
KJSIDS
|
||||||
|
SKSC
|
||||||
|
KJSCE
|
||||||
|
SIRC
|
||||||
|
KJSIM
|
||||||
|
SSA
|
||||||
|
KJSCEd
|
||||||
|
DLIS
|
||||||
|
MSSMPA
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ApplicationStatus {
|
||||||
|
REJECTED
|
||||||
|
ACCEPTED
|
||||||
|
PENDING
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Designation {
|
||||||
|
HOD
|
||||||
|
HOI
|
||||||
|
VC
|
||||||
|
ACCOUNTS
|
||||||
|
FACULTY
|
||||||
|
STUDENT
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
profileId String @id @default(uuid())
|
||||||
|
userName String
|
||||||
|
email String @unique @db.Text
|
||||||
|
password String
|
||||||
|
|
||||||
|
institute Institute?
|
||||||
|
department String?
|
||||||
|
designation Designation
|
||||||
|
|
||||||
|
appliedApplications Application[] @relation("AppliedApplications")
|
||||||
|
toValidateApplications Application[] @relation("ToValidateApplications")
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Application {
|
||||||
|
applicationId String @id @default(uuid())
|
||||||
|
applicantId String
|
||||||
|
applicant User @relation("AppliedApplications", fields: [applicantId], references: [profileId])
|
||||||
|
institute Institute
|
||||||
|
department String
|
||||||
|
|
||||||
|
applicantName String
|
||||||
|
applicationType String
|
||||||
|
formData Json
|
||||||
|
|
||||||
|
formName String
|
||||||
|
resubmission Boolean @default(false)
|
||||||
|
|
||||||
|
facultyValidation ApplicationStatus?
|
||||||
|
hodValidation ApplicationStatus?
|
||||||
|
hoiValidation ApplicationStatus?
|
||||||
|
vcValidation ApplicationStatus?
|
||||||
|
accountsValidation ApplicationStatus?
|
||||||
|
|
||||||
|
rejectionFeedback String?
|
||||||
|
|
||||||
|
totalExpense Float @default(0)
|
||||||
|
|
||||||
|
proofOfTravel Bytes?
|
||||||
|
proofOfAccommodation Bytes?
|
||||||
|
proofOfAttendance Bytes?
|
||||||
|
expenseProof0 Bytes?
|
||||||
|
expenseProof1 Bytes?
|
||||||
|
expenseProof2 Bytes?
|
||||||
|
expenseProof3 Bytes?
|
||||||
|
expenseProof4 Bytes?
|
||||||
|
expenseProof5 Bytes?
|
||||||
|
expenseProof6 Bytes?
|
||||||
|
expenseProof7 Bytes?
|
||||||
|
expenseProof8 Bytes?
|
||||||
|
expenseProof9 Bytes?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
validators User[] @relation("ToValidateApplications")
|
||||||
|
|
||||||
|
@@index([applicantId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
123
backend/prisma/seed.js
Normal file
123
backend/prisma/seed.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import prisma from "../src/config/prismaConfig.js";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Common password for all users
|
||||||
|
const commonPassword = "securePassword123";
|
||||||
|
|
||||||
|
// Applicant and Validator data
|
||||||
|
const institutes = [
|
||||||
|
"KJSIDS",
|
||||||
|
"SKSC",
|
||||||
|
"KJSCE",
|
||||||
|
"SIRC",
|
||||||
|
"KJSIM",
|
||||||
|
"SSA",
|
||||||
|
"KJSCEd",
|
||||||
|
"DLIS",
|
||||||
|
"MSSMPA",
|
||||||
|
];
|
||||||
|
const departments = [
|
||||||
|
"Mechanical",
|
||||||
|
"Electronics",
|
||||||
|
"CBE",
|
||||||
|
"Electronics & Telecommunication",
|
||||||
|
"Computer",
|
||||||
|
"Information Technology",
|
||||||
|
"Science & Humanities",
|
||||||
|
"Admin",
|
||||||
|
"Library",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create VC (single, no department or institute)
|
||||||
|
console.log("Seeding VC...");
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
userName: "Validator_VC",
|
||||||
|
email: "vc@example.com",
|
||||||
|
password: commonPassword,
|
||||||
|
designation: "VC",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const institute of institutes) {
|
||||||
|
// Create HOI for each institute
|
||||||
|
console.log(`Seeding HOI for ${institute}...`);
|
||||||
|
const hoiEmail = `hoi.${institute.toLowerCase()}@example.com`;
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
userName: `HOI_${institute}`,
|
||||||
|
email: hoiEmail,
|
||||||
|
password: commonPassword,
|
||||||
|
institute,
|
||||||
|
designation: "HOI",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Accounts for each institute
|
||||||
|
console.log(`Seeding Accounts for ${institute}...`);
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
userName: `Validator_Accounts_${institute}`,
|
||||||
|
email: `accounts.${institute.toLowerCase()}@example.com`,
|
||||||
|
password: commonPassword,
|
||||||
|
institute,
|
||||||
|
designation: "ACCOUNTS",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const department of departments) {
|
||||||
|
// Create HOD for each department of each institute
|
||||||
|
console.log(`Seeding HOD for ${department} in ${institute}...`);
|
||||||
|
const hodEmail = `hod.${department.toLowerCase()}.${institute.toLowerCase()}@example.com`;
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
userName: `HOD_${department}_${institute}`,
|
||||||
|
email: hodEmail,
|
||||||
|
password: commonPassword,
|
||||||
|
institute,
|
||||||
|
department,
|
||||||
|
designation: "HOD",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Faculty for each department of each institute
|
||||||
|
console.log(`Seeding Faculty for ${department} in ${institute}...`);
|
||||||
|
const facultyEmail = `faculty.${department.toLowerCase()}.${institute.toLowerCase()}@example.com`;
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
userName: `Faculty_${department}_${institute}`,
|
||||||
|
email: facultyEmail,
|
||||||
|
password: commonPassword,
|
||||||
|
institute,
|
||||||
|
department,
|
||||||
|
designation: "FACULTY",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Create Student for each department of each institute
|
||||||
|
console.log(`Seeding Student for ${department} in ${institute}...`);
|
||||||
|
const studentEmail = `student.${department.toLowerCase()}.${institute.toLowerCase()}@example.com`;
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
userName: `Student_${department}_${institute}`,
|
||||||
|
email: studentEmail,
|
||||||
|
password: commonPassword,
|
||||||
|
institute,
|
||||||
|
department,
|
||||||
|
designation: "STUDENT",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log("Seeding completed!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
}).finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
29
backend/src/app.js
Normal file
29
backend/src/app.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import router from './routes/auth.js';
|
||||||
|
import applicantRoute from './routes/applicant.js';
|
||||||
|
import validatorRoute from './routes/validator.js';
|
||||||
|
import generalRoute from './routes/general.js';
|
||||||
|
import { verifyApplicantToken, verifyToken, verifyValidatorToken } from './middleware/verifyJwt.js';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Middleware setup
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(cors({
|
||||||
|
origin: true,
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Route-specific middleware and routes
|
||||||
|
app.use('/applicant', verifyApplicantToken, applicantRoute);
|
||||||
|
app.use('/validator', verifyValidatorToken, validatorRoute);
|
||||||
|
app.use('/general', verifyToken, generalRoute);
|
||||||
|
|
||||||
|
// Authentication routes
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
|
export default app;
|
||||||
16
backend/src/config/designations.js
Normal file
16
backend/src/config/designations.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const applicantDesignations = [
|
||||||
|
'STUDENT',
|
||||||
|
'FACULTY',
|
||||||
|
'HOD',
|
||||||
|
'HOI',
|
||||||
|
];
|
||||||
|
|
||||||
|
const validatorDesignations = [
|
||||||
|
'FACULTY',
|
||||||
|
'HOD',
|
||||||
|
'HOI',
|
||||||
|
'VC',
|
||||||
|
'ACCOUNTS',
|
||||||
|
];
|
||||||
|
|
||||||
|
export {applicantDesignations, validatorDesignations}
|
||||||
5
backend/src/config/prismaConfig.js
Normal file
5
backend/src/config/prismaConfig.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
434
backend/src/controllers/applicantControllers.js
Normal file
434
backend/src/controllers/applicantControllers.js
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
import { application } from "express";
|
||||||
|
import prisma from "../config/prismaConfig.js";
|
||||||
|
import sendMail from "../services/sendMail.js";
|
||||||
|
|
||||||
|
const createApplication = async (req, res) => {
|
||||||
|
const {
|
||||||
|
id: applicantId,
|
||||||
|
email,
|
||||||
|
designation: applicantDesignation,
|
||||||
|
department,
|
||||||
|
institute,
|
||||||
|
role,
|
||||||
|
} = req.user;
|
||||||
|
|
||||||
|
const formData = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (role !== "applicant") {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.send({ message: "Forbidden, Sign In as Applicant" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const applicant = await prisma.user.findUnique({
|
||||||
|
where: { profileId: applicantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!applicant) {
|
||||||
|
return res.status(404).send({ message: "User not Found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for the formName
|
||||||
|
// if form name is "Travel Intimation Form", then do nothing
|
||||||
|
// if form name is "Post Travel Form" then get the "intimationApplicationID" from the form and check if an application with that id exists if not then return an error
|
||||||
|
// then check if that form dosent have any validation pending or rejected
|
||||||
|
// if it has then return an error
|
||||||
|
// if not then create the application
|
||||||
|
|
||||||
|
const formName = formData.formName;
|
||||||
|
if (!formName){
|
||||||
|
return res.status(400).send({ message: "Form Name is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formName === "Post Travel Form") {
|
||||||
|
const intimationApplicationID = formData.intimationApplicationID;
|
||||||
|
if (intimationApplicationID === null) {
|
||||||
|
return res.status(400).send({ message: "Intimation Application ID is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const intimationApplication = await prisma.application.findUnique({
|
||||||
|
where: {
|
||||||
|
applicationId: intimationApplicationID,
|
||||||
|
applicantId: applicantId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!intimationApplication) {
|
||||||
|
return res.status(404).send({ message: "Intimation Application not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( intimationApplication["formName"] !== "Travel Intimation Form") {
|
||||||
|
return res.status(400).send({ message: "Intimation Application ID is not of a Travel Intimation Form" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationFields = [
|
||||||
|
"facultyValidation",
|
||||||
|
"hodValidation",
|
||||||
|
"hoiValidation",
|
||||||
|
"vcValidation",
|
||||||
|
"accountsValidation",
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasRejectedValidations = validationFields.some(
|
||||||
|
(field) => intimationApplication[field] === "REJECTED"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasRejectedValidations) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.send({ message: "Intimation Application has rejected validations" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPendingValidations = validationFields.some(
|
||||||
|
(field) => intimationApplication[field] === "PENDING"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasPendingValidations) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.send({ message: "Intimation Application has pending validations" });
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const applicantName = applicant.userName;
|
||||||
|
|
||||||
|
let primarySupervisor,
|
||||||
|
anotherSupervisor,
|
||||||
|
hod,
|
||||||
|
hoi,
|
||||||
|
vc = null;
|
||||||
|
|
||||||
|
switch (applicant.designation) {
|
||||||
|
case "STUDENT":
|
||||||
|
primarySupervisor = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
email: formData.primarySupervisorEmail,
|
||||||
|
department,
|
||||||
|
designation: "FACULTY",
|
||||||
|
institute,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!primarySupervisor) {
|
||||||
|
return res.status(404).send({ message: "Faculty not found (Incorrect Primary Supervisor Email)" });
|
||||||
|
}
|
||||||
|
anotherSupervisor = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
email: formData.anotherSupervisorEmail,
|
||||||
|
department: formData.anotherSupervisorDepartment,
|
||||||
|
designation: "FACULTY",
|
||||||
|
institute,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!anotherSupervisor && formData.anotherSupervisorEmail) {
|
||||||
|
return res.status(404).send({ message: "Another Supervisor not found" });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "FACULTY":
|
||||||
|
hod = await prisma.user.findFirst({
|
||||||
|
where: { department, designation: "HOD", institute },
|
||||||
|
});
|
||||||
|
if (!hod) {
|
||||||
|
return res.status(404).send({ message: "HOD not found" });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "HOD":
|
||||||
|
hoi = await prisma.user.findFirst({
|
||||||
|
where: { designation: "HOI", institute },
|
||||||
|
});
|
||||||
|
if (!hoi) {
|
||||||
|
return res.status(404).send({ message: "HOI not found" });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "HOI":
|
||||||
|
vc = await prisma.user.findFirst({
|
||||||
|
where: { designation: "VC" },
|
||||||
|
});
|
||||||
|
if (!vc) {
|
||||||
|
return res.status(404).send({ message: "VC not found" });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security check: Make sure the proper relationships exist between the applicant and application data
|
||||||
|
if (applicant.designation === "STUDENT") {
|
||||||
|
// Validate that primary supervisor email is provided for student applications
|
||||||
|
if (!formData.primarySupervisorEmail) {
|
||||||
|
return res.status(400).send({ message: "Primary supervisor email is required for student applications" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile the validators list with available supervisors, FDC coordinator, HOD, and HOI
|
||||||
|
const validators = [
|
||||||
|
primarySupervisor && { profileId: primarySupervisor?.profileId },
|
||||||
|
anotherSupervisor && { profileId: anotherSupervisor?.profileId },
|
||||||
|
hod && { profileId: hod?.profileId },
|
||||||
|
hoi && { profileId: hoi?.profileId },
|
||||||
|
vc && { profileId: vc?.profileId },
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const {
|
||||||
|
proofOfTravel,
|
||||||
|
proofOfAccommodation,
|
||||||
|
proofOfAttendance,
|
||||||
|
...otherFiles
|
||||||
|
} = req.files;
|
||||||
|
|
||||||
|
// Prepare file buffers for fixed fields
|
||||||
|
const proofOfTravelBuffer = proofOfTravel?.[0]?.buffer || null;
|
||||||
|
const proofOfAccommodationBuffer =
|
||||||
|
proofOfAccommodation?.[0]?.buffer || null;
|
||||||
|
const proofOfAttendanceBuffer = proofOfAttendance?.[0]?.buffer || null;
|
||||||
|
|
||||||
|
// Prepare an object to hold the expense proof buffers dynamically
|
||||||
|
const expenseProofs = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const expenseProofField = `expenses[${i}].expenseProof`;
|
||||||
|
if (otherFiles[expenseProofField]) {
|
||||||
|
expenseProofs[`expenseProof${i}`] =
|
||||||
|
otherFiles[expenseProofField][0].buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expenses = JSON.parse(formData.expenses);
|
||||||
|
|
||||||
|
const totalExpense = parseFloat(
|
||||||
|
expenses.reduce(
|
||||||
|
(total, { expenseAmount }) => total + +expenseAmount,
|
||||||
|
0
|
||||||
|
).toFixed(2)
|
||||||
|
);
|
||||||
|
|
||||||
|
formData["totalExpense"] = totalExpense;
|
||||||
|
|
||||||
|
// Construct the application data object
|
||||||
|
const applicationData = {
|
||||||
|
applicantName,
|
||||||
|
department,
|
||||||
|
institute,
|
||||||
|
totalExpense,
|
||||||
|
formData,
|
||||||
|
proofOfTravel: proofOfTravelBuffer,
|
||||||
|
proofOfAccommodation: proofOfAccommodationBuffer,
|
||||||
|
proofOfAttendance: proofOfAttendanceBuffer,
|
||||||
|
...expenseProofs, // Add dynamically generated expense proof fields
|
||||||
|
facultyValidation: ["STUDENT"].includes(applicant.designation)
|
||||||
|
? "PENDING"
|
||||||
|
: undefined,
|
||||||
|
hodValidation: ["FACULTY"].includes(applicant.designation)
|
||||||
|
? "PENDING"
|
||||||
|
: undefined,
|
||||||
|
hoiValidation: ["HOD"].includes(applicant.designation)
|
||||||
|
? "PENDING"
|
||||||
|
: undefined,
|
||||||
|
vcValidation: ["HOI"].includes(applicant.designation)
|
||||||
|
? "PENDING"
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new application entry with linked applicant and validators
|
||||||
|
const newApplication = await prisma.application.create({
|
||||||
|
data: {
|
||||||
|
...applicationData,
|
||||||
|
applicationType: applicant.designation === "STUDENT" ? "STUDENT" : "FACULTY",
|
||||||
|
applicant: {
|
||||||
|
connect: { profileId: applicantId },
|
||||||
|
},
|
||||||
|
validators: {
|
||||||
|
connect: validators,
|
||||||
|
},
|
||||||
|
formName: formData.formName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store this original state to compare on future modifications
|
||||||
|
// We don't need to actually create a new record, the application itself
|
||||||
|
// will serve this purpose
|
||||||
|
|
||||||
|
// sendMail({
|
||||||
|
// emailId: hod.email,
|
||||||
|
// link: `http://localhost:5173/validator/dashboard/pending/${newApplication.applicationId}`,
|
||||||
|
// type: "validator",
|
||||||
|
// status: null,
|
||||||
|
// designation: null,
|
||||||
|
// });
|
||||||
|
|
||||||
|
res.status(201).send({
|
||||||
|
message: "Application created successfully",
|
||||||
|
data: newApplication.applicantName,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating application:", error);
|
||||||
|
res.status(500).send({
|
||||||
|
message: "Error creating application",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateApplication = async (req, res) => {
|
||||||
|
const {
|
||||||
|
id: applicantId,
|
||||||
|
email,
|
||||||
|
designation: applicantDesignation,
|
||||||
|
department,
|
||||||
|
institute,
|
||||||
|
role,
|
||||||
|
} = req.user;
|
||||||
|
|
||||||
|
const formData = req.body;
|
||||||
|
const applicationId = formData.applicationId;
|
||||||
|
delete formData.applicationId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (role !== "applicant") {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.send({ message: "Forbidden, Sign In as Applicant" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const applicant = await prisma.user.findUnique({
|
||||||
|
where: { profileId: applicantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!applicant) {
|
||||||
|
return res.status(404).send({ message: "User not Found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the original application to compare fields
|
||||||
|
const originalApplication = await prisma.application.findUnique({
|
||||||
|
where: { applicationId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!originalApplication) {
|
||||||
|
return res.status(404).send({ message: "Application not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalFormData = originalApplication.formData;
|
||||||
|
|
||||||
|
// Verify that disabled fields haven't been changed
|
||||||
|
// Only expenses can be edited, everything else should remain the same regardless of resubmission status
|
||||||
|
|
||||||
|
// Create a clone of the original form data for comparison
|
||||||
|
const safeOriginalData = JSON.parse(JSON.stringify(originalFormData));
|
||||||
|
|
||||||
|
// Create a safe copy of submitted form data for validation
|
||||||
|
const safeSubmittedData = { ...formData };
|
||||||
|
|
||||||
|
// Exclude expenses-related fields from comparison as they're allowed to change
|
||||||
|
delete safeOriginalData.expenses;
|
||||||
|
delete safeSubmittedData.expenses;
|
||||||
|
delete safeSubmittedData.resubmission;
|
||||||
|
delete safeOriginalData.totalExpense;
|
||||||
|
delete safeSubmittedData.totalExpense;
|
||||||
|
|
||||||
|
delete safeOriginalData?.proofOfTravel;
|
||||||
|
delete safeOriginalData?.proofOfAccommodation;
|
||||||
|
delete safeSubmittedData?.proofOfAttendance;
|
||||||
|
|
||||||
|
// We need to check if any non-expenses fields have been modified
|
||||||
|
for (const key in safeSubmittedData) {
|
||||||
|
// Don't check expense fields pattern (expenses[0].expenseProof, etc.)
|
||||||
|
if (key.startsWith('expenses[') || key === 'proofOfTravel' || key === 'proofOfAccommodation' || key === 'proofOfAttendance') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the field has been modified
|
||||||
|
if (safeSubmittedData[key] !== safeOriginalData[key]) {
|
||||||
|
console.log(`Tampering detected: Field '${key}' was modified`);
|
||||||
|
console.log(`Original: ${safeOriginalData[key]}`);
|
||||||
|
console.log(`Submitted: ${safeSubmittedData[key]}`);
|
||||||
|
|
||||||
|
return res.status(403).send({
|
||||||
|
message: `Forbidden: Field '${key}' cannot be modified. Only expense information can be changed. Tampering detected.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
proofOfTravel,
|
||||||
|
proofOfAccommodation,
|
||||||
|
proofOfAttendance,
|
||||||
|
...otherFiles
|
||||||
|
} = req.files;
|
||||||
|
|
||||||
|
// Prepare file buffers for fixed fields
|
||||||
|
const proofOfTravelBuffer = proofOfTravel?.[0]?.buffer || null;
|
||||||
|
const proofOfAccommodationBuffer =
|
||||||
|
proofOfAccommodation?.[0]?.buffer || null;
|
||||||
|
const proofOfAttendanceBuffer = proofOfAttendance?.[0]?.buffer || null;
|
||||||
|
|
||||||
|
// Prepare an object to hold the expense proof buffers dynamically
|
||||||
|
const expenseProofs = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const expenseProofField = `expenses[${i}].expenseProof`;
|
||||||
|
if (otherFiles[expenseProofField]) {
|
||||||
|
expenseProofs[`expenseProof${i}`] =
|
||||||
|
otherFiles[expenseProofField][0].buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expenses = JSON.parse(formData.expenses);
|
||||||
|
|
||||||
|
const totalExpense = parseFloat(
|
||||||
|
expenses.reduce(
|
||||||
|
(total, { expenseAmount }) => total + +expenseAmount,
|
||||||
|
0
|
||||||
|
).toFixed(2)
|
||||||
|
);
|
||||||
|
|
||||||
|
formData["totalExpense"] = totalExpense;
|
||||||
|
|
||||||
|
const validationFields = [
|
||||||
|
"facultyValidation",
|
||||||
|
"hodValidation",
|
||||||
|
"hoiValidation",
|
||||||
|
"vcValidation",
|
||||||
|
"accountsValidation",
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log(expenseProofs)
|
||||||
|
|
||||||
|
const updatedData = {
|
||||||
|
totalExpense,
|
||||||
|
formData,
|
||||||
|
proofOfTravel: proofOfTravelBuffer,
|
||||||
|
proofOfAccommodation: proofOfAccommodationBuffer,
|
||||||
|
proofOfAttendance: proofOfAttendanceBuffer,
|
||||||
|
resubmission: false,
|
||||||
|
...expenseProofs,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const field of validationFields) {
|
||||||
|
if (originalApplication[field] === "REJECTED") {
|
||||||
|
updatedData[field] = "PENDING";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedApplication = await prisma.application.update({
|
||||||
|
where: { applicationId },
|
||||||
|
data: updatedData,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send({
|
||||||
|
message: "Application updated successfully",
|
||||||
|
data: updatedApplication.applicantName,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({
|
||||||
|
message: "Error updating application",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createApplication, updateApplication };
|
||||||
130
backend/src/controllers/authControllers.js
Normal file
130
backend/src/controllers/authControllers.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import prisma from "../config/prismaConfig.js";
|
||||||
|
import generateToken from "../services/generateToken.js";
|
||||||
|
|
||||||
|
const applicantLogin = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
// Check if the applicant profile exists
|
||||||
|
const validProfile = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
email
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validProfile) {
|
||||||
|
return res.status(404).json({
|
||||||
|
message: "Applicant User Doesn't exist",
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the password is correct
|
||||||
|
if (validProfile.password !== password) {
|
||||||
|
return res.status(404).json({
|
||||||
|
message: "Wrong Password",
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token object
|
||||||
|
const tokenObject = {
|
||||||
|
id: validProfile.profileId,
|
||||||
|
designation: validProfile.designation,
|
||||||
|
department: validProfile.department,
|
||||||
|
institute: validProfile.institute,
|
||||||
|
role: "applicant",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate the token
|
||||||
|
const token = generateToken(tokenObject);
|
||||||
|
|
||||||
|
// Set the token as a cookie
|
||||||
|
return res
|
||||||
|
.cookie("access_token", token, { sameSite: 'None', secure: true, httpOnly: true })
|
||||||
|
.status(200)
|
||||||
|
.json({
|
||||||
|
message: "Login Successful",
|
||||||
|
data: { username: validProfile.userName, token },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
message: "Internal Server Error",
|
||||||
|
data: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validatorLogin = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
// Check if the validator profile exists
|
||||||
|
let validProfile = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
email
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validProfile) {
|
||||||
|
return res.status(404).json({
|
||||||
|
message: "Validator User Doesn't exist",
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the password is correct
|
||||||
|
if (validProfile.password !== password) {
|
||||||
|
return res.status(404).json({
|
||||||
|
message: "Wrong Password",
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token object
|
||||||
|
const tokenObject = {
|
||||||
|
id: validProfile.profileId,
|
||||||
|
designation: validProfile.designation,
|
||||||
|
department: validProfile.department,
|
||||||
|
institute: validProfile.institute,
|
||||||
|
role: "validator",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate the token
|
||||||
|
const token = generateToken(tokenObject);
|
||||||
|
|
||||||
|
// Set the token as a cookie
|
||||||
|
return res
|
||||||
|
.cookie("access_token", token, { sameSite: 'None', secure: true, httpOnly: true })
|
||||||
|
.status(200)
|
||||||
|
.json({
|
||||||
|
message: "Login Successful",
|
||||||
|
data: { username: validProfile.userName, token },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
message: "Internal Server Error",
|
||||||
|
data: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Clear the access token cookie
|
||||||
|
res.clearCookie("access_token", { httpOnly: true });
|
||||||
|
|
||||||
|
// Respond with success message
|
||||||
|
return res.status(200).json({
|
||||||
|
message: "Logout Successful",
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
message: "Internal Server Error",
|
||||||
|
data: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { applicantLogin, validatorLogin, logout };
|
||||||
115
backend/src/controllers/formatter.js
Normal file
115
backend/src/controllers/formatter.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
const u = {
|
||||||
|
expenses:
|
||||||
|
'[{"expenseCategory":"TRAVEL","expenseName":"Rustic Granite Ball","expenseAmount":3696,"expenseProof":{}}]',
|
||||||
|
cadreSize: "",
|
||||||
|
eventName: "Rustic Metal Chair",
|
||||||
|
eventVenue: "",
|
||||||
|
applicantAge: "",
|
||||||
|
eventEndDate: "",
|
||||||
|
eventWebsite: "",
|
||||||
|
modeOfTravel: "",
|
||||||
|
totalExpense: 3696,
|
||||||
|
typeOfTravel: "",
|
||||||
|
applicantIDNo: "",
|
||||||
|
proofOfTravel: "",
|
||||||
|
travelEndDate: "",
|
||||||
|
applicantEmail: "",
|
||||||
|
durationOfStay: "",
|
||||||
|
eventStartDate: "",
|
||||||
|
applicationType: "Individual",
|
||||||
|
cadreMember1Age: "",
|
||||||
|
cadreMember2Age: "",
|
||||||
|
cadreMember3Age: "",
|
||||||
|
cadreMember4Age: "",
|
||||||
|
cadreMember5Age: "",
|
||||||
|
cadreMember6Age: "",
|
||||||
|
cadreMember7Age: "",
|
||||||
|
cadreMember8Age: "",
|
||||||
|
cadreMember9Age: "",
|
||||||
|
purposeOfTravel: "",
|
||||||
|
travelStartDate: "",
|
||||||
|
applicantAddress: "3491 Margarette Plains",
|
||||||
|
applicantContact: "249-639-5239",
|
||||||
|
cadreMember10Age: "",
|
||||||
|
cadreMember1IDNo: "",
|
||||||
|
cadreMember2IDNo: "",
|
||||||
|
cadreMember3IDNo: "",
|
||||||
|
cadreMember4IDNo: "",
|
||||||
|
cadreMember5IDNo: "",
|
||||||
|
cadreMember6IDNo: "",
|
||||||
|
cadreMember7IDNo: "",
|
||||||
|
cadreMember8IDNo: "",
|
||||||
|
cadreMember9IDNo: "",
|
||||||
|
applicantFullName: "Rick Bahringer",
|
||||||
|
cadreMember10IDNo: "",
|
||||||
|
cadreMember1Email: "",
|
||||||
|
cadreMember2Email: "",
|
||||||
|
cadreMember3Email: "",
|
||||||
|
cadreMember4Email: "",
|
||||||
|
cadreMember5Email: "",
|
||||||
|
cadreMember6Email: "",
|
||||||
|
cadreMember7Email: "",
|
||||||
|
cadreMember8Email: "",
|
||||||
|
cadreMember9Email: "",
|
||||||
|
modeOfTravelOther: "",
|
||||||
|
proofOfAttendance: "",
|
||||||
|
accommodationOpted: "false",
|
||||||
|
applicantInstitute: "KJSCE",
|
||||||
|
cadreMember10Email: "",
|
||||||
|
applicantDepartment: "Computer",
|
||||||
|
cadreMember1Address: "",
|
||||||
|
cadreMember1Contact: "",
|
||||||
|
cadreMember2Address: "",
|
||||||
|
cadreMember2Contact: "",
|
||||||
|
cadreMember3Address: "",
|
||||||
|
cadreMember3Contact: "",
|
||||||
|
cadreMember4Address: "",
|
||||||
|
cadreMember4Contact: "",
|
||||||
|
cadreMember5Address: "",
|
||||||
|
cadreMember5Contact: "",
|
||||||
|
cadreMember6Address: "",
|
||||||
|
cadreMember6Contact: "",
|
||||||
|
cadreMember7Address: "",
|
||||||
|
cadreMember7Contact: "",
|
||||||
|
cadreMember8Address: "",
|
||||||
|
cadreMember8Contact: "",
|
||||||
|
cadreMember9Address: "",
|
||||||
|
cadreMember9Contact: "",
|
||||||
|
typeOfAccommodation: "",
|
||||||
|
accommodationAddress: "",
|
||||||
|
anyOtherRequirements: "",
|
||||||
|
cadreMember10Address: "",
|
||||||
|
cadreMember10Contact: "",
|
||||||
|
cadreMember1FullName: "",
|
||||||
|
cadreMember2FullName: "",
|
||||||
|
cadreMember3FullName: "",
|
||||||
|
cadreMember4FullName: "",
|
||||||
|
cadreMember5FullName: "",
|
||||||
|
cadreMember6FullName: "",
|
||||||
|
cadreMember7FullName: "",
|
||||||
|
cadreMember8FullName: "",
|
||||||
|
cadreMember9FullName: "",
|
||||||
|
proofOfAccommodation: "",
|
||||||
|
purposeOfTravelOther: "",
|
||||||
|
cadreMember10FullName: "",
|
||||||
|
cadreMember1Institute: "",
|
||||||
|
cadreMember2Institute: "",
|
||||||
|
cadreMember3Institute: "",
|
||||||
|
cadreMember4Institute: "",
|
||||||
|
cadreMember5Institute: "",
|
||||||
|
cadreMember6Institute: "",
|
||||||
|
cadreMember7Institute: "",
|
||||||
|
cadreMember8Institute: "",
|
||||||
|
cadreMember9Institute: "",
|
||||||
|
cadreMember10Institute: "",
|
||||||
|
cadreMember1Department: "",
|
||||||
|
cadreMember2Department: "",
|
||||||
|
cadreMember3Department: "",
|
||||||
|
cadreMember4Department: "",
|
||||||
|
cadreMember5Department: "",
|
||||||
|
cadreMember6Department: "",
|
||||||
|
cadreMember7Department: "",
|
||||||
|
cadreMember8Department: "",
|
||||||
|
cadreMember9Department: "",
|
||||||
|
cadreMember10Department: "",
|
||||||
|
};
|
||||||
439
backend/src/controllers/generalControllers.js
Normal file
439
backend/src/controllers/generalControllers.js
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
import { application } from "express";
|
||||||
|
import prisma from "../config/prismaConfig.js";
|
||||||
|
import {
|
||||||
|
applicantDesignations,
|
||||||
|
validatorDesignations,
|
||||||
|
} from "../config/designations.js";
|
||||||
|
|
||||||
|
const dataRoot = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user; // Contains all user info (id, designation, department, etc.)
|
||||||
|
const { id: profileId, email, designation, department, role } = user;
|
||||||
|
|
||||||
|
if (applicantDesignations.includes(designation) && role === "applicant") {
|
||||||
|
const applicant = await prisma.user.findUnique({
|
||||||
|
where: { profileId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!applicant) {
|
||||||
|
return res
|
||||||
|
.status(404)
|
||||||
|
.json({ message: "Applicant not found", data: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
delete applicant.password;
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
message: "Applicant Authorized",
|
||||||
|
user: applicant,
|
||||||
|
role: "Applicant",
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
validatorDesignations.includes(designation) &&
|
||||||
|
role === "validator"
|
||||||
|
) {
|
||||||
|
const validator = await prisma.user.findUnique({
|
||||||
|
where: { profileId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validator) {
|
||||||
|
return res
|
||||||
|
.status(404)
|
||||||
|
.json({ message: "Validator not found", data: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
delete validator.password;
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
message: "Validator Authorized",
|
||||||
|
user: validator,
|
||||||
|
role: "Validator",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ message: "Unauthorized access", data: null });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Handle any errors that occur during the process
|
||||||
|
console.error(error);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: "Internal Server Error", data: null });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getApplicationsByStatus = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user;
|
||||||
|
const userId = user.id;
|
||||||
|
const take = parseInt(req.query.take) || 5;
|
||||||
|
const skip = parseInt(req.query.skip) || 0;
|
||||||
|
const status = req.params.status.toUpperCase(); // Expected: "PENDING", "ACCEPTED", or "REJECTED"
|
||||||
|
const sortBy = req.query?.sortBy;
|
||||||
|
const sortValue = req.query?.sortValue;
|
||||||
|
|
||||||
|
const validStatuses = ["PENDING", "ACCEPTED", "REJECTED"];
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
return res.status(400).send("Invalid status");
|
||||||
|
}
|
||||||
|
|
||||||
|
let applications, totalApplications;
|
||||||
|
|
||||||
|
// Filter conditions for Student and Faculty
|
||||||
|
if (
|
||||||
|
applicantDesignations.includes(user.designation) &&
|
||||||
|
user.role === "applicant"
|
||||||
|
) {
|
||||||
|
const baseWhere = {
|
||||||
|
applicantId: userId,
|
||||||
|
...(status === "PENDING" && {
|
||||||
|
OR: [
|
||||||
|
{ facultyValidation: "PENDING" },
|
||||||
|
{ hodValidation: "PENDING" },
|
||||||
|
{ hoiValidation: "PENDING" },
|
||||||
|
{ vcValidation: "PENDING" },
|
||||||
|
{ accountsValidation: "PENDING" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
...(status === "ACCEPTED" && {
|
||||||
|
AND: [
|
||||||
|
{ OR: [{ facultyValidation: "ACCEPTED" }, { facultyValidation: null }] },
|
||||||
|
{ OR: [{ hodValidation: "ACCEPTED" }, { hodValidation: null }] },
|
||||||
|
{ OR: [{ hoiValidation: "ACCEPTED" }, { hoiValidation: null }] },
|
||||||
|
{ OR: [{ vcValidation: "ACCEPTED" }, { vcValidation: null }] },
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ accountsValidation: "ACCEPTED" },
|
||||||
|
{ accountsValidation: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
...(status === "REJECTED" && {
|
||||||
|
OR: [
|
||||||
|
{ facultyValidation: "REJECTED" },
|
||||||
|
{ hodValidation: "REJECTED" },
|
||||||
|
{ hoiValidation: "REJECTED" },
|
||||||
|
{ vcValidation: "REJECTED" },
|
||||||
|
{ accountsValidation: "REJECTED" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply case-insensitive filter for search functionality
|
||||||
|
if (sortBy && sortValue) {
|
||||||
|
baseWhere[sortBy] = {
|
||||||
|
contains: sortValue,
|
||||||
|
mode: "insensitive",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count and fetch applications
|
||||||
|
totalApplications = await prisma.application.count({ where: baseWhere });
|
||||||
|
applications = await prisma.application.findMany({
|
||||||
|
where: baseWhere,
|
||||||
|
select: {
|
||||||
|
applicationId: true,
|
||||||
|
applicantName: true,
|
||||||
|
formData: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
take,
|
||||||
|
skip,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter conditions for Validators (Supervisor, HOD, HOI, FDCcoordinator)
|
||||||
|
} else if (
|
||||||
|
validatorDesignations.includes(user.designation) &&
|
||||||
|
user.role === "validator"
|
||||||
|
) {
|
||||||
|
const validationField = `${user.designation.toLowerCase()}Validation`;
|
||||||
|
|
||||||
|
const baseWhere = {
|
||||||
|
validators: { some: { profileId: userId } },
|
||||||
|
[validationField]: status,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sortBy && sortValue) {
|
||||||
|
baseWhere[sortBy] = {
|
||||||
|
contains: sortValue,
|
||||||
|
mode: "insensitive",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
totalApplications = await prisma.application.count({
|
||||||
|
where: baseWhere,
|
||||||
|
});
|
||||||
|
|
||||||
|
applications = await prisma.application.findMany({
|
||||||
|
where: baseWhere,
|
||||||
|
select: {
|
||||||
|
applicationId: true,
|
||||||
|
applicantName: true,
|
||||||
|
formData: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
take,
|
||||||
|
skip,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Unauthorized access for other user roles
|
||||||
|
return res.status(403).send("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format response with selected fields
|
||||||
|
const responseApplications = applications.map((application) => ({
|
||||||
|
applicationId: application.applicationId,
|
||||||
|
applicantName: application.applicantName,
|
||||||
|
formData: {
|
||||||
|
eventName: application.formData.eventName,
|
||||||
|
applicantDepartment: application.formData.applicantDepartment,
|
||||||
|
},
|
||||||
|
createdAt: application.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
message: `${status} Applications Fetched Successfully`,
|
||||||
|
totalApplications,
|
||||||
|
applications: responseApplications,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).send(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getApplicationData = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const applicationId = req.params.applicationId;
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
// Fetch application data excluding proof fields
|
||||||
|
const applicationFull = await prisma.application.findUnique({
|
||||||
|
where: {
|
||||||
|
applicationId: applicationId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
applicationId: true,
|
||||||
|
applicantId: true,
|
||||||
|
applicantName: true,
|
||||||
|
resubmission: true,
|
||||||
|
formData: true,
|
||||||
|
facultyValidation: true,
|
||||||
|
hodValidation: true,
|
||||||
|
hoiValidation: true,
|
||||||
|
vcValidation: true,
|
||||||
|
accountsValidation: true,
|
||||||
|
rejectionFeedback: true,
|
||||||
|
createdAt: true,
|
||||||
|
applicant: {
|
||||||
|
select: {
|
||||||
|
designation: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validators: {
|
||||||
|
select: {
|
||||||
|
profileId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
applicationFull?.applicantId !== user.id &&
|
||||||
|
!applicationFull.validators.some(
|
||||||
|
(validator) => validator.profileId === user.id
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return res.status(403).json({
|
||||||
|
message: "Unauthorized",
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!applicationFull) {
|
||||||
|
return res.status(404).json({
|
||||||
|
message: "Application not found",
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentStatus;
|
||||||
|
|
||||||
|
// Check if the user is an applicant or a validator
|
||||||
|
if (
|
||||||
|
applicantDesignations.includes(user.designation) &&
|
||||||
|
user.role === "applicant"
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
applicationFull.facultyValidation === "PENDING" ||
|
||||||
|
applicationFull.hodValidation === "PENDING" ||
|
||||||
|
applicationFull.hoiValidation === "PENDING" ||
|
||||||
|
applicationFull.vcValidation === "PENDING" ||
|
||||||
|
applicationFull.accountsValidation === "PENDING"
|
||||||
|
) {
|
||||||
|
currentStatus = "PENDING";
|
||||||
|
} else if (
|
||||||
|
applicationFull.facultyValidation === "REJECTED" ||
|
||||||
|
applicationFull.supervisorValidation === "REJECTED" ||
|
||||||
|
applicationFull.hodValidation === "REJECTED" ||
|
||||||
|
applicationFull.hoiValidation === "REJECTED" ||
|
||||||
|
applicationFull.fdccoordinatorValidation === "REJECTED"
|
||||||
|
) {
|
||||||
|
currentStatus = "REJECTED";
|
||||||
|
} else {
|
||||||
|
currentStatus = "ACCEPTED";
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
validatorDesignations.includes(user.designation) &&
|
||||||
|
user.role === "validator"
|
||||||
|
) {
|
||||||
|
const validationField = `${user.designation.toLowerCase()}Validation`;
|
||||||
|
|
||||||
|
if (applicationFull[validationField] === "ACCEPTED") {
|
||||||
|
currentStatus = "ACCEPTED";
|
||||||
|
} else if (applicationFull[validationField] === "REJECTED") {
|
||||||
|
currentStatus = "REJECTED";
|
||||||
|
} else {
|
||||||
|
currentStatus = "PENDING";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(403).json({
|
||||||
|
message: "Unauthorized",
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond with the full application data and current status
|
||||||
|
return res.status(200).json({
|
||||||
|
message: "Application data retrieved successfully",
|
||||||
|
data: { ...applicationFull, currentStatus },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error retrieving application data:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
message: "An error occurred while retrieving the application data",
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFile = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { applicationId, fileName } = req.params;
|
||||||
|
const user = req.user;
|
||||||
|
const userId = user.id;
|
||||||
|
|
||||||
|
const validFileNames = [
|
||||||
|
"proofOfTravel",
|
||||||
|
"proofOfAccommodation",
|
||||||
|
"proofOfAttendance",
|
||||||
|
"expenseProof0",
|
||||||
|
"expenseProof1",
|
||||||
|
"expenseProof2",
|
||||||
|
"expenseProof3",
|
||||||
|
"expenseProof4",
|
||||||
|
"expenseProof5",
|
||||||
|
"expenseProof6",
|
||||||
|
"expenseProof7",
|
||||||
|
"expenseProof8",
|
||||||
|
"expenseProof9",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!validFileNames.includes(fileName)) {
|
||||||
|
return res.status(400).json({ error: "Invalid File request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSelection = {
|
||||||
|
proofOfTravel: false,
|
||||||
|
proofOfAccommodation: false,
|
||||||
|
proofOfAttendance: false,
|
||||||
|
expenseProof0: false,
|
||||||
|
expenseProof1: false,
|
||||||
|
expenseProof2: false,
|
||||||
|
expenseProof3: false,
|
||||||
|
expenseProof4: false,
|
||||||
|
expenseProof5: false,
|
||||||
|
expenseProof6: false,
|
||||||
|
expenseProof7: false,
|
||||||
|
expenseProof8: false,
|
||||||
|
expenseProof9: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (validFileNames.includes(fileName)) {
|
||||||
|
fileSelection[fileName] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let myApplication, myFile;
|
||||||
|
|
||||||
|
if (
|
||||||
|
applicantDesignations.includes(user.designation) &&
|
||||||
|
user.role === "applicant"
|
||||||
|
) {
|
||||||
|
myApplication = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
profileId: userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
appliedApplications: {
|
||||||
|
where: {
|
||||||
|
applicationId,
|
||||||
|
},
|
||||||
|
select: fileSelection,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
myFile = myApplication?.appliedApplications[0];
|
||||||
|
} else if (
|
||||||
|
validatorDesignations.includes(user.designation) &&
|
||||||
|
user.role === "validator"
|
||||||
|
) {
|
||||||
|
myApplication = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
profileId: userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
toValidateApplications: {
|
||||||
|
where: {
|
||||||
|
applicationId,
|
||||||
|
},
|
||||||
|
select: fileSelection,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
myFile = myApplication?.toValidateApplications[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!myFile) {
|
||||||
|
return res.status(404).json({ error: "File not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the file buffer dynamically based on the fileName
|
||||||
|
const fileBuffer = myFile[fileName];
|
||||||
|
|
||||||
|
// If file buffer doesn't exist
|
||||||
|
if (!fileBuffer) {
|
||||||
|
return res.status(404).json({ error: "File content not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set response headers for PDF file download
|
||||||
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="${fileName}.pdf"`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send the file buffer as a response
|
||||||
|
return res.send(fileBuffer);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error retrieving application data:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "An error occurred while retrieving the application data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getApplicationData, getFile, dataRoot, getApplicationsByStatus };
|
||||||
437
backend/src/controllers/validatorController.js
Normal file
437
backend/src/controllers/validatorController.js
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import { validatorDesignations } from "../config/designations.js";
|
||||||
|
import prisma from "../config/prismaConfig.js";
|
||||||
|
import sendMail from "../services/sendMail.js";
|
||||||
|
|
||||||
|
const applicationAction = async (req, res) => {
|
||||||
|
const { id: profileId, designation, department, institute, role } = req.user;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
applicationId,
|
||||||
|
action,
|
||||||
|
rejectionFeedback,
|
||||||
|
toVC,
|
||||||
|
resubmission,
|
||||||
|
expenses,
|
||||||
|
} = req.body; // actions = 'accepted' or 'rejected'
|
||||||
|
|
||||||
|
if (role !== "validator") {
|
||||||
|
return res.status(403).send("Forbidden, Sign in as a validator");
|
||||||
|
}
|
||||||
|
|
||||||
|
const validator = await prisma.user.findFirst({
|
||||||
|
where: { profileId },
|
||||||
|
include: {
|
||||||
|
toValidateApplications: {
|
||||||
|
where: { applicationId },
|
||||||
|
include: {
|
||||||
|
validators: {
|
||||||
|
select: { profileId: true, designation: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validator) {
|
||||||
|
return res.status(404).send("Validator doesn't exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
const application = validator.toValidateApplications[0];
|
||||||
|
|
||||||
|
if (!application) {
|
||||||
|
return res.status(404).send("Application not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const applicant = await prisma.user.findFirst({
|
||||||
|
where: { profileId: application.applicantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const applicantDesignation = applicant.designation;
|
||||||
|
const applicantDepartment = applicant.department;
|
||||||
|
const applicantInstitute = applicant.institute;
|
||||||
|
const applicantEmail = applicant.email;
|
||||||
|
|
||||||
|
const validationStatus = action.toUpperCase();
|
||||||
|
let resubmissionStatus = JSON.parse(resubmission) || false;
|
||||||
|
|
||||||
|
if (validationStatus !== "ACCEPTED" && validationStatus !== "REJECTED") {
|
||||||
|
return res.status(400).send("Invalid status");
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationData = {};
|
||||||
|
let hod,
|
||||||
|
hoi,
|
||||||
|
vc,
|
||||||
|
accounts = null;
|
||||||
|
|
||||||
|
switch (validator.designation) {
|
||||||
|
case "FACULTY":
|
||||||
|
if (application.facultyValidation != "PENDING") {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.send("Already performed an action, can't change status again");
|
||||||
|
}
|
||||||
|
validationData.facultyValidation = validationStatus;
|
||||||
|
if (validationStatus === "ACCEPTED") {
|
||||||
|
validationData.hodValidation = "PENDING";
|
||||||
|
hod = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
designation: "HOD",
|
||||||
|
department: applicantDepartment,
|
||||||
|
institute: applicantInstitute,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
sendMail({
|
||||||
|
emailId: hod.email,
|
||||||
|
link: `http://localhost:5173/validator/dashboard/pending/${applicationId}`,
|
||||||
|
type: "validator",
|
||||||
|
status: null,
|
||||||
|
designation: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendMail({
|
||||||
|
emailId: applicantEmail,
|
||||||
|
link: `http://localhost:5173/applicant/dashboard/${validationStatus}/${applicationId}`,
|
||||||
|
type: "applicant",
|
||||||
|
status: validationStatus,
|
||||||
|
designation: "FACULTY",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "HOD":
|
||||||
|
if (application.hodValidation != "PENDING") {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.send("Already performed an action, can't change status again");
|
||||||
|
}
|
||||||
|
validationData.hodValidation = validationStatus;
|
||||||
|
if (validationStatus === "ACCEPTED") {
|
||||||
|
validationData.hoiValidation = "PENDING";
|
||||||
|
hoi = await prisma.user.findFirst({
|
||||||
|
where: { designation: "HOI", institute: applicantInstitute },
|
||||||
|
});
|
||||||
|
|
||||||
|
sendMail({
|
||||||
|
emailId: hoi.email,
|
||||||
|
link: `http://localhost:5173/validator/dashboard/pending/${applicationId}`,
|
||||||
|
type: "validator",
|
||||||
|
status: null,
|
||||||
|
designation: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendMail({
|
||||||
|
emailId: applicantEmail,
|
||||||
|
link: `http://localhost:5173/applicant/dashboard/${validationStatus}/${applicationId}`,
|
||||||
|
type: "applicant",
|
||||||
|
status: validationStatus,
|
||||||
|
designation: "HOD",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "HOI":
|
||||||
|
if (application.hoiValidation != "PENDING") {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.send("Already performed an action, can't change status again");
|
||||||
|
}
|
||||||
|
validationData.hoiValidation = validationStatus;
|
||||||
|
|
||||||
|
if (validationStatus === "ACCEPTED") {
|
||||||
|
if (JSON.parse(toVC)) {
|
||||||
|
if (applicantDesignation === "STUDENT") {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
message:
|
||||||
|
"Students Applications cannot be forwared for VC validation",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
validationData.vcValidation = "PENDING";
|
||||||
|
vc = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
designation: "VC",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
sendMail({
|
||||||
|
emailId: vc.email,
|
||||||
|
link: `http://localhost:5173/validator/dashboard/pending/${applicationId}`,
|
||||||
|
type: "validator",
|
||||||
|
status: null,
|
||||||
|
designation: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
validationData.accountsValidation = "PENDING";
|
||||||
|
accounts = await prisma.user.findFirst({
|
||||||
|
where: { designation: "ACCOUNTS", institute: applicantInstitute },
|
||||||
|
});
|
||||||
|
sendMail({
|
||||||
|
emailId: accounts.email,
|
||||||
|
link: `http://localhost:5173/validator/dashboard/pending/${applicationId}`,
|
||||||
|
type: "accounts",
|
||||||
|
status: null,
|
||||||
|
designation: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendMail({
|
||||||
|
emailId: applicantEmail,
|
||||||
|
link: `http://localhost:5173/applicant/dashboard/${validationStatus}/${applicationId}`,
|
||||||
|
type: "applicant",
|
||||||
|
status: validationStatus,
|
||||||
|
designation: "HOI",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "VC":
|
||||||
|
if (application.vcValidation != "PENDING") {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.send("Already performed an action, can't change status again");
|
||||||
|
}
|
||||||
|
validationData.vcValidation = validationStatus;
|
||||||
|
if (validationStatus === "ACCEPTED") {
|
||||||
|
validationData.accountsValidation = "PENDING";
|
||||||
|
accounts = await prisma.user.findFirst({
|
||||||
|
where: { designation: "ACCOUNTS", institute: applicantInstitute },
|
||||||
|
});
|
||||||
|
sendMail({
|
||||||
|
emailId: accounts.email,
|
||||||
|
link: `http://localhost:5173/validator/dashboard/pending/${applicationId}`,
|
||||||
|
type: "accounts",
|
||||||
|
status: null,
|
||||||
|
designation: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendMail({
|
||||||
|
emailId: applicantEmail,
|
||||||
|
link: `http://localhost:5173/applicant/dashboard/${validationStatus}/${applicationId}`,
|
||||||
|
type: "applicant",
|
||||||
|
status: validationStatus,
|
||||||
|
designation: "VC",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "ACCOUNTS":
|
||||||
|
if (application.accountsValidation != "PENDING") {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.send("Already performed an action, can't change status again");
|
||||||
|
}
|
||||||
|
validationData.accountsValidation = validationStatus;
|
||||||
|
sendMail({
|
||||||
|
emailId: applicantEmail,
|
||||||
|
link: `http://localhost:5173/applicant/dashboard/${validationStatus}/${applicationId}`,
|
||||||
|
type: "applicant",
|
||||||
|
status: validationStatus,
|
||||||
|
designation: "ACCOUNTS",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return res.status(400).send("Invalid validator designation");
|
||||||
|
}
|
||||||
|
|
||||||
|
const validators = [
|
||||||
|
hod && { profileId: hod?.profileId },
|
||||||
|
hoi && { profileId: hoi?.profileId },
|
||||||
|
vc && { profileId: vc?.profileId },
|
||||||
|
accounts && { profileId: accounts?.profileId },
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (rejectionFeedback) {
|
||||||
|
validationData.rejectionFeedback = rejectionFeedback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationStatus === "ACCEPTED") {
|
||||||
|
validationData.rejectionFeedback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFormData = application.formData;
|
||||||
|
|
||||||
|
if (expenses) {
|
||||||
|
newFormData.expenses = expenses;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await prisma.application.update({
|
||||||
|
where: {
|
||||||
|
applicationId: applicationId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...validationData,
|
||||||
|
resubmission: resubmissionStatus,
|
||||||
|
validators: {
|
||||||
|
connect: validators,
|
||||||
|
},
|
||||||
|
formData: newFormData,
|
||||||
|
},
|
||||||
|
select: { applicationId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(response);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const expenseAction = async (req, res) => {
|
||||||
|
const { id: profileId, designation, department, institute, role } = req.user;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { applicationId, expense, action } = req.body;
|
||||||
|
|
||||||
|
if (role !== "validator") {
|
||||||
|
return res.status(403).send("Forbidden, Sign in as a validator");
|
||||||
|
}
|
||||||
|
|
||||||
|
const validator = await prisma.user.findFirst({
|
||||||
|
where: { profileId },
|
||||||
|
include: {
|
||||||
|
toValidateApplications: {
|
||||||
|
where: { applicationId },
|
||||||
|
include: {
|
||||||
|
validators: {
|
||||||
|
select: { profileId: true, designation: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validator) {
|
||||||
|
return res.status(404).send("Validator doesn't exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
const application = validator.toValidateApplications[0];
|
||||||
|
|
||||||
|
if (!application) {
|
||||||
|
return res.status(404).send("Application not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedFormData = {
|
||||||
|
...application.formData,
|
||||||
|
expenses: JSON.stringify(
|
||||||
|
JSON.parse(application.formData.expenses).map((singleExpense) =>
|
||||||
|
singleExpense.expenseId === expense.expenseId
|
||||||
|
? { ...singleExpense, proofStatus: action }
|
||||||
|
: singleExpense
|
||||||
|
)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedApplication = await prisma.application.update({
|
||||||
|
where: {
|
||||||
|
applicationId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
formData: updatedFormData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(updatedApplication);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getApplicantNames = async (req, res) => {
|
||||||
|
const { id: profileId, designation, department, institute, role } = req.user;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (role !== "validator") {
|
||||||
|
return res.status(403).send("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const applicants = await prisma.application.findMany({
|
||||||
|
where: { validators: { some: { profileId } } },
|
||||||
|
select: {
|
||||||
|
applicantName: true,
|
||||||
|
},
|
||||||
|
distinct: ["applicantName"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ApplicantNames = applicants.map((application) => ({
|
||||||
|
key: application.applicantName.toLowerCase(),
|
||||||
|
value: application.applicantName,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(200).send(ApplicantNames);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).send(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getReportData = async (req, res) => {
|
||||||
|
const { institute, department, year, applicationType } = req.query;
|
||||||
|
const {
|
||||||
|
id: profileId,
|
||||||
|
designation,
|
||||||
|
department: ogDepartment,
|
||||||
|
institute: ogInstitute,
|
||||||
|
role,
|
||||||
|
} = req.user;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
(ogDepartment && department !== ogDepartment) ||
|
||||||
|
(ogInstitute && institute !== ogInstitute)
|
||||||
|
) {
|
||||||
|
return res.status(403).send("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = {
|
||||||
|
institute,
|
||||||
|
department,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (year) {
|
||||||
|
whereClause.createdAt = {
|
||||||
|
gte: new Date(`${year}-01-01`),
|
||||||
|
lt: new Date(`${year}-12-31`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applicationType) {
|
||||||
|
whereClause.applicationType = applicationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applications = await prisma.application.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportData = {
|
||||||
|
totalApplications: applications.length,
|
||||||
|
acceptedApplications: applications.filter(
|
||||||
|
(application) =>
|
||||||
|
(application.facultyValidation === "ACCEPTED" ||
|
||||||
|
application.facultyValidation === null) &&
|
||||||
|
(application.hodValidation === "ACCEPTED" ||
|
||||||
|
application.hodValidation === null) &&
|
||||||
|
(application.hoiValidation === "ACCEPTED" ||
|
||||||
|
application.hoiValidation === null) &&
|
||||||
|
(application.vcValidation === "ACCEPTED" ||
|
||||||
|
application.vcValidation === null) &&
|
||||||
|
(application.accountsValidation === "ACCEPTED" ||
|
||||||
|
application.accountsValidation === null)
|
||||||
|
),
|
||||||
|
rejectedApplications: applications.filter(
|
||||||
|
(application) =>
|
||||||
|
application.facultyValidation === "REJECTED" ||
|
||||||
|
application.hodValidation === "REJECTED" ||
|
||||||
|
application.hoiValidation === "REJECTED" ||
|
||||||
|
application.vcValidation === "REJECTED" ||
|
||||||
|
application.accountsValidation === "REJECTED"
|
||||||
|
),
|
||||||
|
pendingApplications: applications.filter(
|
||||||
|
(application) =>
|
||||||
|
application.facultyValidation === "PENDING" ||
|
||||||
|
application.hodValidation === "PENDING" ||
|
||||||
|
application.hoiValidation === "PENDING" ||
|
||||||
|
application.vcValidation === "PENDING" ||
|
||||||
|
application.accountsValidation === "PENDING"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).send(reportData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).send(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { applicationAction, expenseAction, getApplicantNames, getReportData };
|
||||||
24
backend/src/middleware/upload.js
Normal file
24
backend/src/middleware/upload.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import multer from 'multer';
|
||||||
|
|
||||||
|
const storage = multer.memoryStorage();
|
||||||
|
const upload = multer({ storage });
|
||||||
|
|
||||||
|
const uploadExpenses = (req, res, next) => {
|
||||||
|
|
||||||
|
const expenseProofFields = Array.from({ length: 10 }, (_, index) => ({
|
||||||
|
name: `expenses[${index}].expenseProof`,
|
||||||
|
maxCount: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
{ name: 'proofOfTravel', maxCount: 1 },
|
||||||
|
{ name: 'proofOfAccommodation', maxCount: 1 },
|
||||||
|
{ name: 'proofOfAttendance', maxCount: 1 },
|
||||||
|
...expenseProofFields,
|
||||||
|
];
|
||||||
|
|
||||||
|
upload.fields(fields)(req, res, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default uploadExpenses;
|
||||||
97
backend/src/middleware/verifyJwt.js
Normal file
97
backend/src/middleware/verifyJwt.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { applicantDesignations, validatorDesignations } from '../config/designations.js';
|
||||||
|
|
||||||
|
const verifyApplicantToken = (req, res, next) => {
|
||||||
|
|
||||||
|
const token = req.cookies.access_token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({message: "No token found"});
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt.verify(token, process.env.JWT_SECRET, (err,payload)=>{
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return res.status(403).json({message:"Invalid Token"});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
id : payload.id,
|
||||||
|
designation : payload.designation,
|
||||||
|
department : payload.department,
|
||||||
|
institute : payload.institute,
|
||||||
|
role : payload.role
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.user && applicantDesignations.includes(req.user.designation)) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
return res.status(401).json({message : "Access denied. Not a applicant."});
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyValidatorToken = (req, res, next) => {
|
||||||
|
|
||||||
|
const token = req.cookies.access_token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({message: "No token found"});
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt.verify(token, process.env.JWT_SECRET, (err,payload)=>{
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return res.status(403).json({message:"Invalid Token"});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
id : payload.id,
|
||||||
|
designation : payload.designation,
|
||||||
|
department : payload.department,
|
||||||
|
institute : payload.institute,
|
||||||
|
role : payload.role
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user && validatorDesignations.includes(req.user.designation)) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
return res.status(401).json({message:"Access denied. Not a validator."});
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyToken = (req, res, next) => {
|
||||||
|
|
||||||
|
const token = req.cookies.access_token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({message: "No token found"});
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt.verify(token, process.env.JWT_SECRET, (err,payload)=>{
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return res.status(403).json({message:"Invalid Token"});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
id : payload.id,
|
||||||
|
designation : payload.designation,
|
||||||
|
department : payload.department,
|
||||||
|
institute : payload.institute,
|
||||||
|
role : payload.role
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user && [...applicantDesignations ,...validatorDesignations].includes(req.user.designation)) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
return res.status(401).json({message:"Access denied. Not a validator."});
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export { verifyApplicantToken, verifyValidatorToken, verifyToken} ;
|
||||||
18
backend/src/routes/applicant.js
Normal file
18
backend/src/routes/applicant.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import uploadFields from '../middleware/upload.js';
|
||||||
|
import { createApplication, updateApplication } from '../controllers/applicantControllers.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post("/create-application",
|
||||||
|
uploadFields,
|
||||||
|
createApplication
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put("/resubmit-application",
|
||||||
|
uploadFields,
|
||||||
|
updateApplication
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
export default router;
|
||||||
11
backend/src/routes/auth.js
Normal file
11
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { applicantLogin, logout, validatorLogin } from '../controllers/authControllers.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/applicant-login', applicantLogin);
|
||||||
|
router.post('/validator-login', validatorLogin);
|
||||||
|
|
||||||
|
router.get('/logout', logout)
|
||||||
|
|
||||||
|
export default router;
|
||||||
16
backend/src/routes/general.js
Normal file
16
backend/src/routes/general.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { dataRoot, getApplicationData, getApplicationsByStatus, getFile } from '../controllers/generalControllers.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/dataRoot", dataRoot );
|
||||||
|
|
||||||
|
router.get('/getApplications/:status', getApplicationsByStatus);
|
||||||
|
|
||||||
|
router.get("/getApplicationData/:applicationId", getApplicationData);
|
||||||
|
|
||||||
|
router.get("/getFile/:applicationId/:fileName", getFile)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default router;
|
||||||
16
backend/src/routes/validator.js
Normal file
16
backend/src/routes/validator.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import {applicationAction, expenseAction, getApplicantNames, getReportData} from '../controllers/validatorController.js'
|
||||||
|
import multer from 'multer';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const upload = multer();
|
||||||
|
|
||||||
|
router.put("/statusAction", upload.none(), applicationAction)
|
||||||
|
|
||||||
|
router.put("/expenseAction", expenseAction)
|
||||||
|
|
||||||
|
router.get("/getApplicantNames", getApplicantNames)
|
||||||
|
|
||||||
|
router.get("/getReportData", getReportData)
|
||||||
|
|
||||||
|
export default router;
|
||||||
9
backend/src/server.js
Normal file
9
backend/src/server.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import app from './app.js';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Server is running on port ${port}`);
|
||||||
|
})
|
||||||
12
backend/src/services/generateToken.js
Normal file
12
backend/src/services/generateToken.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
function generateToken(tokenObject) {
|
||||||
|
|
||||||
|
return jwt.sign(
|
||||||
|
tokenObject,
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: '1h' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default generateToken;
|
||||||
74
backend/src/services/sendMail.js
Normal file
74
backend/src/services/sendMail.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import nodeMailer from 'nodemailer';
|
||||||
|
|
||||||
|
export default async function sendMail({ emailId, link, type, status, designation }) {
|
||||||
|
if (!process.env.TravelPolicyEmail || !process.env.TravelPolicyEmailPass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("parametrs", emailId, link, type, status, designation);
|
||||||
|
|
||||||
|
const transporter = nodeMailer.createTransport({
|
||||||
|
service: 'gmail',
|
||||||
|
port: 465,
|
||||||
|
secure: true,
|
||||||
|
logger: true,
|
||||||
|
debug: true,
|
||||||
|
secureConnection: false,
|
||||||
|
auth: {
|
||||||
|
user: process.env.TravelPolicyEmail,
|
||||||
|
pass: process.env.TravelPolicyEmailPass,
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let mailOptions;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'validator':
|
||||||
|
mailOptions = {
|
||||||
|
from: process.env.TravelPolicyEmail,
|
||||||
|
to: emailId,
|
||||||
|
subject: 'You have a new travel policy application to review',
|
||||||
|
html: `
|
||||||
|
<p>You have a new travel policy application to review. Please click on the link below to review the application:</p>
|
||||||
|
<a href=${link}>Review Application</a>
|
||||||
|
<p>Thank you.</p>
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'applicant':
|
||||||
|
mailOptions = {
|
||||||
|
from: process.env.TravelPolicyEmail,
|
||||||
|
to: emailId,
|
||||||
|
subject: `Your travel policy application status: ${status}`,
|
||||||
|
html: `
|
||||||
|
<p>Your travel policy application has been ${status} by ${designation}. Please click on the link below to view the status of your application:</p>
|
||||||
|
<a href=${link}>View Application</a>
|
||||||
|
<p>Thank you.</p>
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'accounts':
|
||||||
|
mailOptions = {
|
||||||
|
from: process.env.TravelPolicyEmail,
|
||||||
|
to: emailId,
|
||||||
|
subject: 'Transfer money to the applicant',
|
||||||
|
html: `
|
||||||
|
<p>Please transfer the travel policy amount to the applicant's account. Click on the link below to view the application:</p>`
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid email type');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transporter.sendMail(mailOptions);
|
||||||
|
console.log('Email sent successfully');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending email:', error);
|
||||||
|
return { status: 'error', message: 'Verification code not sent' };
|
||||||
|
}
|
||||||
|
}
|
||||||
21
frontend/.eslintrc.cjs
Normal file
21
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: { browser: true, es2020: true },
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:react/recommended',
|
||||||
|
'plugin:react/jsx-runtime',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
],
|
||||||
|
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||||
|
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||||
|
settings: { react: { version: '18.2' } },
|
||||||
|
plugins: ['react-refresh'],
|
||||||
|
rules: {
|
||||||
|
'react/jsx-no-target-blank': 'off',
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
27
frontend/.gitignore
vendored
Normal file
27
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# 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?
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
8
frontend/README.md
Normal file
8
frontend/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# React + 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/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
24
frontend/index.html
Normal file
24
frontend/index.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + React</title>
|
||||||
|
<style>
|
||||||
|
html,body,#root{
|
||||||
|
height:100vh;
|
||||||
|
width:100vw;
|
||||||
|
margin:0;
|
||||||
|
padding:0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* overflow: hidden; */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7144
frontend/package-lock.json
generated
Normal file
7144
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
frontend/package.json
Normal file
47
frontend/package.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/renderer": "^4.1.6",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"axios": "^1.7.5",
|
||||||
|
"bootstrap": "^5.3.3",
|
||||||
|
"chart.js": "^4.4.7",
|
||||||
|
"chartjs-plugin-datalabels": "^2.2.0",
|
||||||
|
"formik": "^2.4.6",
|
||||||
|
"framer-motion": "^11.15.0",
|
||||||
|
"frontend": "file:",
|
||||||
|
"hamburger-react": "^2.5.1",
|
||||||
|
"pdfjs-dist": "^4.7.76",
|
||||||
|
"postcss": "^8.4.40",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-chartjs-2": "^5.2.0",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-icons": "^5.2.1",
|
||||||
|
"react-pdf": "^9.1.1",
|
||||||
|
"react-router-dom": "^6.25.1",
|
||||||
|
"react-search-box": "^2.3.0",
|
||||||
|
"react-table": "^7.8.0",
|
||||||
|
"react-toastify": "^11.0.5",
|
||||||
|
"tailwindcss": "^3.4.7",
|
||||||
|
"yup": "^1.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^1.3.2",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-react": "^7.34.3",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.7",
|
||||||
|
"vite": "^6.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
frontend/public/images/KJSCE-Logo.svg
Normal file
1
frontend/public/images/KJSCE-Logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/public/images/Trust.jpg
Normal file
BIN
frontend/public/images/Trust.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
BIN
frontend/public/images/campus_bg.jpeg
Normal file
BIN
frontend/public/images/campus_bg.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 317 KiB |
BIN
frontend/public/images/logo.jpeg
Normal file
BIN
frontend/public/images/logo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
25
frontend/src/App.css
Normal file
25
frontend/src/App.css
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#root {
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topLevelFormContainer::-webkit-scrollbar {
|
||||||
|
width: 0px;
|
||||||
|
}
|
||||||
76
frontend/src/App.jsx
Normal file
76
frontend/src/App.jsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||||
|
import React, { Suspense } from "react";
|
||||||
|
import "./App.css";
|
||||||
|
import { ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
|
||||||
|
// Lazy loading the pages and components
|
||||||
|
const Login = React.lazy(() => import("./pages/Login/Login"));
|
||||||
|
const Root = React.lazy(() => import("./components/DashboardRoot/Root"));
|
||||||
|
const Dashboard = React.lazy(() => import("./pages/Dashboard/Dashboard"));
|
||||||
|
const Form = React.lazy(() => import("./pages/ApplicationForm/Form"));
|
||||||
|
const About = React.lazy(() => import("./pages/About/About"));
|
||||||
|
const Policy = React.lazy(() => import("./pages/Policy/Policy"));
|
||||||
|
const Applications = React.lazy(() => import("./pages/Applications/Applications"));
|
||||||
|
const Report = React.lazy(() => import("./pages/Report/Report"));
|
||||||
|
const LoginRoot = React.lazy(() => import("./components/LoginRoot/LoginRoot"));
|
||||||
|
const ContactUs = React.lazy(() => import("./pages/ContactUs/ContactUs"));
|
||||||
|
const ApplicationView = React.lazy(() => import("./pages/ApplicationView/ApplicationView"));
|
||||||
|
|
||||||
|
import userDataLoader from "./services/userDataLoader";
|
||||||
|
import { upsertApplicationAction } from "./services/upsertApplicationAction";
|
||||||
|
import { applicationStatusAction } from "./services/applicationStatusAction";
|
||||||
|
import Loading from "./components/Loading";
|
||||||
|
|
||||||
|
// Define the router with lazy-loaded components
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <LoginRoot />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <Login /> },
|
||||||
|
{ path: "about", element: <About /> },
|
||||||
|
{ path: "policy", element: <Policy /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/applicant",
|
||||||
|
element: <Root />,
|
||||||
|
id: "Applicant-Root",
|
||||||
|
loader: userDataLoader,
|
||||||
|
children: [
|
||||||
|
{ path: "dashboard", element: <Dashboard /> },
|
||||||
|
{ path: "dashboard/:status", element: <Applications /> },
|
||||||
|
{ path: "dashboard/:status/:applicationId", element: <ApplicationView />, action: upsertApplicationAction },
|
||||||
|
{ path: "form", element: <Form />, action: upsertApplicationAction },
|
||||||
|
{ path: "contact-us", element: <ContactUs /> },
|
||||||
|
{ path: "policy", element: <Policy /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/validator",
|
||||||
|
element: <Root />,
|
||||||
|
id: "Validator-Root",
|
||||||
|
loader: userDataLoader,
|
||||||
|
children: [
|
||||||
|
{ path: "dashboard", element: <Dashboard /> },
|
||||||
|
{ path: "dashboard/:status", element: <Applications /> },
|
||||||
|
{ path: "dashboard/:status/:applicationId", element: <ApplicationView />, action: applicationStatusAction },
|
||||||
|
{ path: "report", element: <Report /> },
|
||||||
|
{ path: "policy", element: <Policy /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ToastContainer position="top-center" />
|
||||||
|
<Suspense fallback={<Loading/>}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</Suspense>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
0
frontend/src/assets/.keep
Normal file
0
frontend/src/assets/.keep
Normal file
141
frontend/src/components/BaseData.jsx
Normal file
141
frontend/src/components/BaseData.jsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
const institutes = [
|
||||||
|
{ label: "K J Somaiya Institute of Dharma Studies", value: "KJSIDS" },
|
||||||
|
{ label: "S K Somaiya College", value: "SKSC" },
|
||||||
|
{ label: "K J Somaiya College of Engineering", value: "KJSCE" },
|
||||||
|
{ label: "Somaiya Institute for Research and Consultancy", value: "SIRC" },
|
||||||
|
{ label: "K J Somaiya Institute of Management", value: "KJSIM" },
|
||||||
|
{ label: "Somaiya Sports Academy", value: "SSA" },
|
||||||
|
{ label: "K J Somaiya College of Education", value: "KJSCEd" },
|
||||||
|
{ label: "Department of Library and Information Science", value: "DLIS" },
|
||||||
|
{
|
||||||
|
label: "Maya Somaiya School of Music and Performing Arts",
|
||||||
|
value: "MSSMPA",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const instituteDepartmentMapping = {
|
||||||
|
KJSIDS: [
|
||||||
|
{ label: "Academics", value: "Academics" },
|
||||||
|
{
|
||||||
|
label: "Bharatiya Sanskriti Peetham",
|
||||||
|
value: "Bharatiya Sanskriti Peetham",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Center for Studies in Jainism",
|
||||||
|
value: "Center for Studies in Jainism",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Department of Ancient Indian History Culture and Archaeology",
|
||||||
|
value: "Department of Ancient Indian History Culture and Archaeology",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Centre For Buddhist Studies",
|
||||||
|
value: "Centre For Buddhist Studies",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
SKSC: [
|
||||||
|
{
|
||||||
|
label: "Information Technology & Computer Science",
|
||||||
|
value: "Information Technology & Computer Science",
|
||||||
|
},
|
||||||
|
{ label: "Mathematics & Statistics", value: "Mathematics & Statistics" },
|
||||||
|
{ label: "Mass Communication", value: "Mass Communication" },
|
||||||
|
{ label: "Life Science", value: "Life Science" },
|
||||||
|
{ label: "Business Studies", value: "Business Studies" },
|
||||||
|
{ label: "Polymer Science", value: "Polymer Science" },
|
||||||
|
{
|
||||||
|
label: "Commerce & Business Studies",
|
||||||
|
value: "Commerce & Business Studies",
|
||||||
|
},
|
||||||
|
{ label: "Accounting & Finance", value: "Accounting & Finance" },
|
||||||
|
{ label: "Commerce", value: "Commerce" },
|
||||||
|
{ label: "Economics", value: "Economics" },
|
||||||
|
{ label: "ENVIRONMENTAL SCIENCES", value: "ENVIRONMENTAL SCIENCES" },
|
||||||
|
{ label: "Language & Literature", value: "Language & Literature" },
|
||||||
|
{ label: "Computer Science & IT", value: "Computer Science & IT" },
|
||||||
|
{ label: "SciSER", value: "SciSER" },
|
||||||
|
{ label: "STATISTICS", value: "STATISTICS" },
|
||||||
|
{ label: "International Studies", value: "International Studies" },
|
||||||
|
{ label: "Banking & Finance", value: "Banking & Finance" },
|
||||||
|
{ label: "Psychology", value: "Psychology" },
|
||||||
|
{ label: "Financial Market", value: "Financial Market" },
|
||||||
|
{ label: "NEUTRACEUTICALS", value: "NEUTRACEUTICALS" },
|
||||||
|
{ label: "Faculty of Science - SVU", value: "Faculty of Science - SVU" },
|
||||||
|
],
|
||||||
|
KJSCE: [
|
||||||
|
{ label: "Mechanical", value: "Mechanical" },
|
||||||
|
{ label: "Electronics", value: "Electronics" },
|
||||||
|
{ label: "CBE", value: "CBE" },
|
||||||
|
{
|
||||||
|
label: "Electronics & Telecommunication",
|
||||||
|
value: "Electronics & Telecommunication",
|
||||||
|
},
|
||||||
|
{ label: "Computer", value: "Computer" },
|
||||||
|
{ label: "Information Technology", value: "Information Technology" },
|
||||||
|
{ label: "Science & Humanities", value: "Science & Humanities" },
|
||||||
|
{ label: "Admin", value: "Admin" },
|
||||||
|
{ label: "Library", value: "Library" },
|
||||||
|
],
|
||||||
|
SIRC: [
|
||||||
|
{
|
||||||
|
label: "Somaiya Institute for Research & Consultancy",
|
||||||
|
value: "Somaiya Institute for Research & Consultancy",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
KJSIM: [
|
||||||
|
{
|
||||||
|
label: "Marketing and International Business",
|
||||||
|
value: "Marketing and International Business",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label:
|
||||||
|
"General Management (Entrepreneurship, Business Communication, Strategy)",
|
||||||
|
value:
|
||||||
|
"General Management (Entrepreneurship, Business Communication, Strategy)",
|
||||||
|
},
|
||||||
|
{ label: "IT", value: "IT" },
|
||||||
|
{
|
||||||
|
label: "Data Science and Technology",
|
||||||
|
value: "Data Science and Technology",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "HUMAN RESOURCES MANAGEMENT",
|
||||||
|
value: "HUMAN RESOURCES MANAGEMENT",
|
||||||
|
},
|
||||||
|
{ label: "MBA-Sports Management", value: "MBA-Sports Management" },
|
||||||
|
{ label: "HCM", value: "HCM" },
|
||||||
|
{ label: "FINANCE AND LAW", value: "FINANCE AND LAW" },
|
||||||
|
{ label: "Business Analytics", value: "Business Analytics" },
|
||||||
|
{
|
||||||
|
label: "PR, Social Media & Data Mining",
|
||||||
|
value: "PR, Social Media & Data Mining",
|
||||||
|
},
|
||||||
|
{ label: "Economics", value: "Economics" },
|
||||||
|
{
|
||||||
|
label: "Operations and Supply Chain Management",
|
||||||
|
value: "Operations and Supply Chain Management",
|
||||||
|
},
|
||||||
|
{ label: "Community Medicine", value: "Community Medicine" },
|
||||||
|
{ label: "Accreditation", value: "Accreditation" },
|
||||||
|
{ label: "Accounts & Finance", value: "Accounts & Finance" },
|
||||||
|
{ label: "GENERAL ADMINISTRATION", value: "GENERAL ADMINISTRATION" },
|
||||||
|
{ label: "Human Resource", value: "Human Resource" },
|
||||||
|
],
|
||||||
|
SSA: [{ label: "Sports", value: "Sports" }],
|
||||||
|
KJSCEd: [{ label: "Education", value: "Education" }],
|
||||||
|
DLIS: [
|
||||||
|
{
|
||||||
|
label: "Department of Library & Information Science",
|
||||||
|
value: "Department of Library & Information Science",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
MSSMPA: [
|
||||||
|
{
|
||||||
|
label: "Maya Somaiya School of Music & Performing Art",
|
||||||
|
value: "Maya Somaiya School of Music & Performing Art",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"": [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export { institutes, instituteDepartmentMapping };
|
||||||
156
frontend/src/components/DashboardRoot/Navbar.jsx
Normal file
156
frontend/src/components/DashboardRoot/Navbar.jsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import logo from "/images/logo.jpeg";
|
||||||
|
import { IoNotifications, IoPerson } from "react-icons/io5";
|
||||||
|
import Hamburger from "hamburger-react";
|
||||||
|
import { FaSignOutAlt } from "react-icons/fa";
|
||||||
|
|
||||||
|
const Navbar = ({ userData, sidebarIsVisible, setSidebarIsVisible }) => {
|
||||||
|
// Mouse cursor tracking for the pull-down effect
|
||||||
|
const [showNavbar, setShowNavbar] = useState(false);
|
||||||
|
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
let res = await fetch(`${import.meta.env.VITE_APP_API_URL}/logout`, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const userDesignation = userData.designation;
|
||||||
|
const userName = userData.userName;
|
||||||
|
|
||||||
|
const [profileData] = useState({
|
||||||
|
name: userName,
|
||||||
|
university: "Somaiya Vidyavihar University",
|
||||||
|
role: userDesignation,
|
||||||
|
});
|
||||||
|
|
||||||
|
const links = [];
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
setShowNavbar(true);
|
||||||
|
setIsSmallScreen(true);
|
||||||
|
} else {
|
||||||
|
setShowNavbar(false);
|
||||||
|
setIsSmallScreen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Set initial visibility based on screen width
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
// Add event listener to handle resize
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
// Cleanup the event listener on component unmount
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
if (e.clientY < 60 && !isSmallScreen) {
|
||||||
|
setShowNavbar(true);
|
||||||
|
} else {
|
||||||
|
setShowNavbar(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Add event listener for mousemove only for large screens
|
||||||
|
if (!isSmallScreen) {
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the event listener
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
};
|
||||||
|
}, [isSmallScreen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header>
|
||||||
|
{/* Navbar with the pull-down effect */}
|
||||||
|
<nav
|
||||||
|
className={`bg-white shadow-md border-b-4 border-gray-200 w-full px-2 z-50 transition-all duration-300 ease-in-out transform ${
|
||||||
|
isSmallScreen
|
||||||
|
? ""
|
||||||
|
: `fixed top-0 left-0 ${showNavbar ? "translate-y-0" : "-translate-y-full"}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-full flex items-center justify-between px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
{/* Hamburger Menu for Mobile */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
<Hamburger
|
||||||
|
toggled={sidebarIsVisible}
|
||||||
|
toggle={setSidebarIsVisible}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo for Desktop */}
|
||||||
|
<Link to="/" className="hidden md:flex items-center">
|
||||||
|
<img src={logo} alt="Somaiya" className="object-contain w-48" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4 text-lg font-medium">
|
||||||
|
{/* Navbar Links */}
|
||||||
|
<div className="hidden sm:flex items-center space-x-2">
|
||||||
|
{links?.map((link, index) => (
|
||||||
|
<div key={index} className="flex items-center space-x-2">
|
||||||
|
<Link
|
||||||
|
to={link.path}
|
||||||
|
className="text-gray-700 hover:bg-red-700 hover:text-white px-4 py-2 rounded-md transition-all duration-200"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
<span>|</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logout Button */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center text-gray-700 hover:bg-red-700 hover:text-white px-1 sm:px-4 py-2 rounded-md transition-all duration-200"
|
||||||
|
>
|
||||||
|
{/* Logout icon */}
|
||||||
|
<FaSignOutAlt className="w-4 h-4 sm:mr-2" />
|
||||||
|
{/* Text hidden on small screens */}
|
||||||
|
<span className="hidden sm:block">Logout</span>
|
||||||
|
</Link>
|
||||||
|
<span>|</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Profile */}
|
||||||
|
{profileData.name && profileData.role && (
|
||||||
|
<div className="flex items-center space-x-2 bg-red-100 p-2 rounded-md">
|
||||||
|
<IoPerson className="text-red-700 text-xl" />
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="text-gray-700 font-semibold">
|
||||||
|
{profileData.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{`${userData.department} ${profileData.role} at ${userData.institute}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
46
frontend/src/components/DashboardRoot/Root.jsx
Normal file
46
frontend/src/components/DashboardRoot/Root.jsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Outlet, useLoaderData } from "react-router-dom";
|
||||||
|
import Navbar from "./Navbar";
|
||||||
|
import Sidebar from "./Sidebar";
|
||||||
|
|
||||||
|
const Root = () => {
|
||||||
|
const { user, role } = useLoaderData()?.data;
|
||||||
|
const [sidebarIsVisible, setSidebarIsVisible] = useState(true)
|
||||||
|
const urlPath = window.location.pathname;
|
||||||
|
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
setSidebarIsVisible(false); // Hide sidebar on small screens
|
||||||
|
} else {
|
||||||
|
setSidebarIsVisible(true); // Show sidebar on larger screens
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Set initial visibility based on screen width
|
||||||
|
setSidebarIsVisible(window.innerWidth >= 768);
|
||||||
|
|
||||||
|
// Add event listener to handle resize
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
// Cleanup the event listener on component unmount
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<Navbar userData={user} role={role} setSidebarIsVisible={setSidebarIsVisible} sidebarIsVisible={sidebarIsVisible} />
|
||||||
|
<div className= "flex h-full bg-gray-100 overflow-auto">
|
||||||
|
{sidebarIsVisible && !(urlPath.split("/").at(-1).includes("dashboard")) && <Sidebar role={role} />}
|
||||||
|
<div className="w-full min-h-full h-screen overflow-y-scroll">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Root;
|
||||||
258
frontend/src/components/DashboardRoot/Sidebar.jsx
Normal file
258
frontend/src/components/DashboardRoot/Sidebar.jsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FaSignOutAlt } from "react-icons/fa";
|
||||||
|
import { Link, NavLink } from "react-router-dom";
|
||||||
|
|
||||||
|
export const handleLogout = async () => {
|
||||||
|
let res = await fetch(`${import.meta.env.VITE_APP_API_URL}/logout`, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Sidebar = ({ role }) => (
|
||||||
|
<div className="w-72 h-screen bg-white p-6 shadow-lg z-10 flex flex-col overflow-y-auto">
|
||||||
|
<div className="mb-8 text-center border-b-2 border-gray-200 pb-6">
|
||||||
|
<div className="bg-white shadow-lg rounded-lg px-4 py-4 border border-gray-300">
|
||||||
|
<h2 className="text-xl font-semibold text-red-700 tracking-tight">
|
||||||
|
{`${role} Portal`}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 text-sm font-medium py-1">
|
||||||
|
Travel Policy SVU
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-grow">
|
||||||
|
<ul className="space-y-4 text-sm">
|
||||||
|
<li className="border-b border-gray-200 pb-2">
|
||||||
|
<NavLink
|
||||||
|
to={`/${role.toLowerCase()}/dashboard`}
|
||||||
|
end
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center text-gray-800 hover:text-white hover:bg-red-700 p-2 rounded ${
|
||||||
|
isActive ? "font-extrabold" : ""
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M3 13l4 4L10 13m5-5h6a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6m-4 6l4 4 4-4"
|
||||||
|
></path>
|
||||||
|
</svg>{" "}
|
||||||
|
Dashboard
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
<li className="text-gray-700 border-b border-gray-200 pb-2">
|
||||||
|
<span className="flex items-center text-gray-800 hover:text-white hover:bg-red-700 p-2 rounded cursor-pointer">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
></path>
|
||||||
|
</svg>{" "}
|
||||||
|
Application Status
|
||||||
|
</span>
|
||||||
|
<ul className="pl-4 mt-2 border-l border-gray-200 ml-2">
|
||||||
|
<li className="border-b border-gray-200 pb-2">
|
||||||
|
<NavLink
|
||||||
|
to={`/${role.toLowerCase()}/dashboard/pending`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center text-gray-600 hover:text-white hover:bg-red-700 p-2 rounded ${
|
||||||
|
isActive ? "font-black" : ""
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M12 3v18m9-9H3"
|
||||||
|
></path>
|
||||||
|
</svg>{" "}
|
||||||
|
Pending
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
<li className="border-b border-gray-200 pb-2">
|
||||||
|
<NavLink
|
||||||
|
to={`/${role.toLowerCase()}/dashboard/accepted`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center text-gray-600 hover:text-white hover:bg-red-700 p-2 rounded ${
|
||||||
|
isActive ? "font-black" : ""
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
></path>
|
||||||
|
</svg>{" "}
|
||||||
|
Accepted
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
<li className="pb-2">
|
||||||
|
<NavLink
|
||||||
|
to={`/${role.toLowerCase()}/dashboard/rejected`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center text-gray-600 hover:text-white hover:bg-red-700 p-2 rounded ${
|
||||||
|
isActive ? "font-black" : ""
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
></path>
|
||||||
|
</svg>{" "}
|
||||||
|
Rejected
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{role === "Applicant" ? (
|
||||||
|
<>
|
||||||
|
<li className="border-b border-gray-200 pb-2">
|
||||||
|
<NavLink
|
||||||
|
to="/applicant/policy"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center text-gray-800 hover:text-white hover:bg-red-700 p-2 rounded ${
|
||||||
|
isActive ? "font-extrabold" : ""
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M19 10v6a2 2 0 01-2 2H7a2 2 0 01-2-2V10a2 2 0 012-2h10a2 2 0 012 2zM10 14h4m-2-2v4"
|
||||||
|
></path>
|
||||||
|
</svg>{" "}
|
||||||
|
Policy
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to="/applicant/contact-us"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center text-gray-800 hover:text-white hover:bg-red-700 p-2 rounded ${
|
||||||
|
isActive ? "font-extrabold" : ""
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M3 10h5l2 6h6l2-6h5"
|
||||||
|
></path>
|
||||||
|
</svg>{" "}
|
||||||
|
Contact Us
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
//role = "Validator"
|
||||||
|
<>
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to="/validator/report"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center text-gray-800 hover:text-white hover:bg-red-700 p-2 rounded ${
|
||||||
|
isActive ? "font-extrabold" : ""
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M3 10h5l2 6h6l2-6h5"
|
||||||
|
></path>
|
||||||
|
</svg>{" "}
|
||||||
|
Report
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{/* Spacer to push logout to the bottom */}
|
||||||
|
<div className="mt-14">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center text-gray-700 hover:bg-red-700 hover:text-white px-4 py-2 rounded-md transition-all duration-200"
|
||||||
|
>
|
||||||
|
<FaSignOutAlt className="w-4 h-4 mr-2" />
|
||||||
|
Logout
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
19
frontend/src/components/ErrorComponent.jsx
Normal file
19
frontend/src/components/ErrorComponent.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useRouteError } from 'react-router-dom';
|
||||||
|
|
||||||
|
// Error component to display error messages
|
||||||
|
const ErrorComponent = () => {
|
||||||
|
const error = useRouteError();
|
||||||
|
// Extracting status and message from the error
|
||||||
|
const status = error.status || 500;
|
||||||
|
const message = error.data.message || 'Something went wrong.';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1em', border: '1px solid red', borderRadius: '5px', backgroundColor: '#fdd', color: '#d00' }}>
|
||||||
|
<h2>Error {status}</h2>
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorComponent;
|
||||||
13
frontend/src/components/Loading.jsx
Normal file
13
frontend/src/components/Loading.jsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { TbLoader3 } from "react-icons/tb";
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col justify-center items-center h-full animate-pulse">
|
||||||
|
<TbLoader3 className="animate-spin text-xl size-24 text-red-700" />
|
||||||
|
<p className="mt-2">Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Loading;
|
||||||
94
frontend/src/components/LoginRoot/LoginRoot.css
Normal file
94
frontend/src/components/LoginRoot/LoginRoot.css
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/* login styles for the Navbar */
|
||||||
|
.login-navbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 10px 20px;
|
||||||
|
width: 100vw;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-navbar-brand .login-logo {
|
||||||
|
height: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.login-navbar-brand .login-logo {
|
||||||
|
height: 25px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-navbar-toggler {
|
||||||
|
display: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-navbar-toggler-icon {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-navbar-collapse {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-navbar-nav {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-nav-item {
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-nav-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #000;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-nav-link.active {
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-trust-logo {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.login-navbar-toggler {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-navbar-collapse {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-navbar-collapse.show {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-navbar-nav {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-nav-item {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
frontend/src/components/LoginRoot/LoginRoot.jsx
Normal file
54
frontend/src/components/LoginRoot/LoginRoot.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Outlet,NavLink } from 'react-router-dom';
|
||||||
|
import './LoginRoot.css';
|
||||||
|
|
||||||
|
//Navlinks to be used later & will be parent route for policy,services and login
|
||||||
|
|
||||||
|
const LoginRoot = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header>
|
||||||
|
<nav className="login-navbar">
|
||||||
|
<a className="login-navbar-brand" href="#">
|
||||||
|
<img src="/images/KJSCE-Logo.svg" alt="Somaiya Vidyavihar University" className="login-logo" />
|
||||||
|
</a>
|
||||||
|
<div className="login-navbar-toggler">
|
||||||
|
<span className="login-navbar-toggler-icon"></span>
|
||||||
|
</div>
|
||||||
|
<div className="login-navbar-collapse" id="loginNavbarNav">
|
||||||
|
<ul className="login-navbar-nav">
|
||||||
|
<li className="login-nav-item">
|
||||||
|
<NavLink className="login-nav-link" to="policy" end>Policy</NavLink>
|
||||||
|
</li>
|
||||||
|
<li className="login-nav-item">
|
||||||
|
<NavLink className="login-nav-link" to="about" end>About</NavLink>
|
||||||
|
</li>
|
||||||
|
<li className="login-nav-item">
|
||||||
|
<NavLink className="login-nav-link" to="" end>Login</NavLink>
|
||||||
|
</li>
|
||||||
|
<li className="login-nav-item">
|
||||||
|
<a className="login-nav-link" href="#">
|
||||||
|
<img src="/images/Trust.jpg" alt="Somaiya Trust" className="login-trust-logo" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Outlet/>
|
||||||
|
|
||||||
|
<footer className="flex items-center justify-center h-6 w-full bg-gray-100 text-gray-800 fixed bottom-0 left-0 z-50">
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
©2024 KJSCE, All Rights Reserved.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginRoot;
|
||||||
33
frontend/src/components/Modal/Modal.jsx
Normal file
33
frontend/src/components/Modal/Modal.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Modal = ({ onClose, children }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed top-0 inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white p-6 rounded-lg relative w-11/12 md:w-3/5 lg:w-2/5 max-h-[85%] h-min overflow-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex justify-start p-2">
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className="absolute top-3 right-3 text-xl font-bold text-gray-700 hover:text-gray-900"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
X
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
105
frontend/src/components/Pagination.jsx
Normal file
105
frontend/src/components/Pagination.jsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function Pagination({ numOfItems, itemsPerPage = 10, currentPage, onPageChange }) {
|
||||||
|
const totalPages = Math.ceil(numOfItems / itemsPerPage);
|
||||||
|
const pages = Array.from({ length: totalPages }, (_, index) => index + 1);
|
||||||
|
const maxPageButtons = 5; // Maximum number of page buttons to display
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
if (currentPage > 1) onPageChange(currentPage - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentPage < totalPages) onPageChange(currentPage + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageButtons = () => {
|
||||||
|
if (totalPages <= maxPageButtons) {
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const half = Math.floor(maxPageButtons / 2);
|
||||||
|
let start = Math.max(1, currentPage - half);
|
||||||
|
let end = Math.min(totalPages, currentPage + half);
|
||||||
|
|
||||||
|
if (currentPage <= half) {
|
||||||
|
end = maxPageButtons;
|
||||||
|
} else if (currentPage + half >= totalPages) {
|
||||||
|
start = totalPages - maxPageButtons + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from({ length: end - start + 1 }, (_, index) => start + index);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
|
||||||
|
<div className="flex flex-1 justify-between sm:hidden">
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={handlePrevious}
|
||||||
|
className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={handleNext}
|
||||||
|
className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Showing <span className="font-medium">{(currentPage - 1) * itemsPerPage + 1}</span> to <span className="font-medium">{Math.min(currentPage * itemsPerPage, numOfItems)}</span> of <span className="font-medium">{numOfItems}</span> applications
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nav className="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={handlePrevious}
|
||||||
|
className="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Previous</span>
|
||||||
|
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fillRule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{getPageButtons().map(page => (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
key={page}
|
||||||
|
onClick={() => onPageChange(page)}
|
||||||
|
aria-current={currentPage === page ? 'page' : undefined}
|
||||||
|
className={`relative inline-flex items-center px-4 py-2 text-sm font-semibold ${currentPage === page ? 'bg-red-700 text-white' : 'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50'} focus:z-20 focus:outline-offset-0`}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={handleNext}
|
||||||
|
className="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Next</span>
|
||||||
|
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fillRule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Pagination;
|
||||||
25
frontend/src/components/PdfViewer.jsx
Normal file
25
frontend/src/components/PdfViewer.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// PdfViewer.js
|
||||||
|
import React from "react";
|
||||||
|
import Modal from "./Modal/Modal";
|
||||||
|
|
||||||
|
function PdfViewer({ fileUrl, setIsModalOpen }) {
|
||||||
|
if (!fileUrl) {
|
||||||
|
return <p>Loading PDF...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={() => setIsModalOpen(false)}>
|
||||||
|
<object data={fileUrl} type="application/pdf" width="100%" height="600px">
|
||||||
|
<p>
|
||||||
|
PDF preview failed. Please{" "}
|
||||||
|
<a href={fileUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
open the PDF
|
||||||
|
</a>{" "}
|
||||||
|
in a new tab.
|
||||||
|
</p>
|
||||||
|
</object>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PdfViewer;
|
||||||
0
frontend/src/hooks/.keep
Normal file
0
frontend/src/hooks/.keep
Normal file
3
frontend/src/index.css
Normal file
3
frontend/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
145
frontend/src/pages/About/About.jsx
Normal file
145
frontend/src/pages/About/About.jsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const About = () => {
|
||||||
|
return (
|
||||||
|
<div className="bg-white text-gray-800">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className="relative bg-red-600 text-white h-[60vh] flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-red-800 bg-opacity-50"></div>
|
||||||
|
<div className="relative z-10 text-center px-6">
|
||||||
|
<h1 className="text-4xl md:text-6xl font-bold mb-4">
|
||||||
|
Welcome to Our Travel Policy
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg md:text-xl">
|
||||||
|
Structured, efficient, and research-focused travel planning.
|
||||||
|
</p>
|
||||||
|
<button type='button' className="mt-6 px-6 py-3 bg-white text-red-600 font-semibold rounded-lg shadow-md hover:bg-red-700 hover:text-white transition duration-300">
|
||||||
|
Learn More
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mission & Vision */}
|
||||||
|
<div className="flex flex-col md:flex-row items-center py-12 px-6 md:px-12 gap-12">
|
||||||
|
<div className="md:w-1/2">
|
||||||
|
<h2 className="text-3xl font-bold mb-4">Our Approach</h2>
|
||||||
|
<p className="text-lg leading-relaxed">
|
||||||
|
Our travel policy for research students and associates ensures a
|
||||||
|
structured process for approvals and financial support, fostering
|
||||||
|
efficiency and alignment with academic and research objectives.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="md:w-1/2">
|
||||||
|
<img
|
||||||
|
src="https://via.placeholder.com/600x400"
|
||||||
|
alt="Travel Policy"
|
||||||
|
className="rounded-lg shadow-lg transform transition duration-300 hover:scale-105"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Achievements & History */}
|
||||||
|
<div className="bg-gray-100 py-12 px-6 md:px-12">
|
||||||
|
<h2 className="text-3xl font-bold text-center mb-8">Policy Highlights</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: "Travel Request Form",
|
||||||
|
description:
|
||||||
|
"Submit detailed forms including purpose, destination, budget, and supporting documentation.",
|
||||||
|
icon: "📄",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Approval Process",
|
||||||
|
description:
|
||||||
|
"Supervisor, department head, and Office of Research must approve for major travels.",
|
||||||
|
icon: "✔️",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Financial Support",
|
||||||
|
description:
|
||||||
|
"Eligibility depends on travel relevance, available funds, and research alignment.",
|
||||||
|
icon: "💰",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Documentation",
|
||||||
|
description:
|
||||||
|
"Attach conference invitations or research collaboration letters for approval.",
|
||||||
|
icon: "📜",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "International Travel",
|
||||||
|
description:
|
||||||
|
"Managed through the Office of Research for additional oversight and funding.",
|
||||||
|
icon: "✈️",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Funding Sources",
|
||||||
|
description:
|
||||||
|
"Includes department funds, institutional grants, or scholarships.",
|
||||||
|
icon: "🏛️",
|
||||||
|
},
|
||||||
|
].map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex flex-col items-center p-6 bg-white shadow-md rounded-lg transition transform hover:scale-105"
|
||||||
|
>
|
||||||
|
<div className="text-4xl mb-4">{item.icon}</div>
|
||||||
|
<h3 className="text-xl font-semibold mb-2">{item.title}</h3>
|
||||||
|
<p className="text-gray-700 text-center">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Why Choose Us */}
|
||||||
|
<div className="py-12 px-6 md:px-12">
|
||||||
|
<h2 className="text-3xl font-bold text-center mb-8">Why Our Policy Stands Out</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: "Efficiency",
|
||||||
|
description: "Streamlined processes for quicker approvals.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Clarity",
|
||||||
|
description: "Clear guidelines for both students and staff.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Supportive",
|
||||||
|
description:
|
||||||
|
"Ensures financial and logistical aid for research-related travel.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Global Outlook",
|
||||||
|
description: "Facilitates international collaborations and exchanges.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Comprehensive",
|
||||||
|
description: "Covers all aspects of academic travel management.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Transparent",
|
||||||
|
description: "Approval criteria and funding sources clearly defined.",
|
||||||
|
},
|
||||||
|
].map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex flex-col items-start p-6 bg-white shadow-md rounded-lg transition transform hover:scale-105"
|
||||||
|
>
|
||||||
|
<h3 className="text-xl font-semibold mb-2">{item.title}</h3>
|
||||||
|
<p className="text-gray-700">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Back to Top */}
|
||||||
|
<div className="text-center py-6">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default About;
|
||||||
204
frontend/src/pages/ApplicationForm/Form.jsx
Normal file
204
frontend/src/pages/ApplicationForm/Form.jsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Formik } from "formik";
|
||||||
|
import Input from "./Input";
|
||||||
|
import {
|
||||||
|
useSubmit,
|
||||||
|
useRouteLoaderData,
|
||||||
|
useNavigation,
|
||||||
|
useParams,
|
||||||
|
} from "react-router-dom";
|
||||||
|
import { studentFormFeilds, facultyFormFeilds } from "./FormFeilds";
|
||||||
|
import * as yup from "yup";
|
||||||
|
|
||||||
|
function Form({
|
||||||
|
prefilledData,
|
||||||
|
applicantDesignation,
|
||||||
|
resubmission = false,
|
||||||
|
onValuesChange,
|
||||||
|
}) {
|
||||||
|
const { role, user } =
|
||||||
|
useRouteLoaderData("Applicant-Root")?.data ||
|
||||||
|
useRouteLoaderData("Validator-Root")?.data;
|
||||||
|
const applicationId = useParams().applicationId || "";
|
||||||
|
|
||||||
|
const submit = useSubmit("upsertApplicationAction");
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const isSubmittingNav = navigation.state === "submitting";
|
||||||
|
let formFeilds = [];
|
||||||
|
let toBeFormFeilds = [];
|
||||||
|
let designation;
|
||||||
|
|
||||||
|
const applicant = useRouteLoaderData("Applicant-Root");
|
||||||
|
|
||||||
|
if (applicantDesignation) {
|
||||||
|
designation = applicantDesignation;
|
||||||
|
} else {
|
||||||
|
designation = applicant?.data?.user?.designation; //Faculty or Student
|
||||||
|
}
|
||||||
|
|
||||||
|
if (designation === "STUDENT") {
|
||||||
|
toBeFormFeilds = studentFormFeilds;
|
||||||
|
} else {
|
||||||
|
toBeFormFeilds = facultyFormFeilds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefilledData) {
|
||||||
|
formFeilds = toBeFormFeilds?.map((section) => {
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
fields: section?.fields?.map((field) => ({
|
||||||
|
...field,
|
||||||
|
disabled:
|
||||||
|
role === "Validator"
|
||||||
|
? true
|
||||||
|
: resubmission && field?.name == "expenses"
|
||||||
|
? false
|
||||||
|
: true,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
formFeilds = toBeFormFeilds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createIntialValuesScheme = (formFields) => {
|
||||||
|
const schema = {};
|
||||||
|
|
||||||
|
formFields?.forEach((section) => {
|
||||||
|
section?.fields?.forEach((field) => {
|
||||||
|
if (prefilledData) {
|
||||||
|
if (field.type === "miniForm") {
|
||||||
|
schema[field.name] = JSON.parse(prefilledData[field.name]);
|
||||||
|
} else if (field.type === "checkbox") {
|
||||||
|
schema[field.name] = JSON.parse(
|
||||||
|
prefilledData[field.name] || "false"
|
||||||
|
);
|
||||||
|
} else if (field.type === "number") {
|
||||||
|
schema[field.name] = parseInt(prefilledData[field.name]);
|
||||||
|
} else {
|
||||||
|
schema[field.name] = prefilledData[field.name];
|
||||||
|
}
|
||||||
|
} else if (field.type === "checkbox") {
|
||||||
|
schema[field.name] = false;
|
||||||
|
} else if (field.type === "miniForm") {
|
||||||
|
schema[field.name] = [];
|
||||||
|
} else {
|
||||||
|
schema[field.name] = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return schema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const intialValuesSchema = createIntialValuesScheme(formFeilds);
|
||||||
|
|
||||||
|
const createValidationSchema = (formFields) => {
|
||||||
|
const schema = {};
|
||||||
|
|
||||||
|
formFields?.forEach((section) => {
|
||||||
|
section.fields?.forEach((field) => {
|
||||||
|
if (field.validation) {
|
||||||
|
schema[field.name] = field.validation;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return yup.object().shape(schema);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationSchema = createValidationSchema(formFeilds);
|
||||||
|
|
||||||
|
const handleSubmit = async (values, { setSubmitting }) => {
|
||||||
|
const formDataToSend = new FormData();
|
||||||
|
|
||||||
|
for (const key in values) {
|
||||||
|
if (key === "expenses") {
|
||||||
|
// Serialize the expenses array as a JSON string and append
|
||||||
|
const expenses = JSON.stringify(values[key]);
|
||||||
|
formDataToSend.append("expenses", expenses);
|
||||||
|
|
||||||
|
// Append expenseProof files separately (as file objects)
|
||||||
|
values[key].forEach((expense, index) => {
|
||||||
|
if (expense.expenseProof) {
|
||||||
|
formDataToSend.append(
|
||||||
|
`expenses[${index}].expenseProof`,
|
||||||
|
expense.expenseProof
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For other fields, just append normally
|
||||||
|
formDataToSend.append(key, values[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formDataToSend.append("resubmission", resubmission);
|
||||||
|
formDataToSend.append("applicationId", applicationId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
submit(formDataToSend, {
|
||||||
|
method: "POST",
|
||||||
|
encType: "multipart/form-data", // Specify the encoding type
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error uploading form:", error.message);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false); // Reset the submitting state after request is done
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={intialValuesSchema}
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
{({
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
handleChange,
|
||||||
|
handleBlur,
|
||||||
|
handleSubmit,
|
||||||
|
setFieldValue, // Use setFieldValue for file handling
|
||||||
|
isSubmitting,
|
||||||
|
}) => {
|
||||||
|
// Notify parent about values change
|
||||||
|
useEffect(() => {
|
||||||
|
if (onValuesChange) {
|
||||||
|
onValuesChange(values);
|
||||||
|
}
|
||||||
|
}, [values, onValuesChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="p-2 my-4 overflow-y-auto bg-transparent"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
values={values}
|
||||||
|
errors={errors}
|
||||||
|
touched={touched}
|
||||||
|
handleChange={handleChange}
|
||||||
|
handleBlur={handleBlur}
|
||||||
|
setFieldValue={setFieldValue} // Pass setFieldValue for file handling
|
||||||
|
formFeilds={formFeilds}
|
||||||
|
/>
|
||||||
|
{(resubmission || !prefilledData) && role != "Validator" && (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || isSubmittingNav}
|
||||||
|
className="w-full flex items-center justify-center bg-gradient-to-r from-red-600 to-red-800 hover:from-red-800 hover:to-red-600 text-white font-semibold py-2 px-4 rounded-lg shadow-lg transform transition duration-300 ease-in-out disabled:bg-gray-400"
|
||||||
|
>
|
||||||
|
{isSubmitting || isSubmittingNav ? "Submitting" : "Submit"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Form;
|
||||||
1107
frontend/src/pages/ApplicationForm/FormFeilds.jsx
Normal file
1107
frontend/src/pages/ApplicationForm/FormFeilds.jsx
Normal file
File diff suppressed because it is too large
Load Diff
379
frontend/src/pages/ApplicationForm/Input.jsx
Normal file
379
frontend/src/pages/ApplicationForm/Input.jsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { studentFormFeilds, facultyFormFeilds } from "./FormFeilds";
|
||||||
|
import { useParams, useRouteLoaderData } from "react-router-dom";
|
||||||
|
import ExpenseForm from "./components/ExpenseForm";
|
||||||
|
import ExpenseTable from "./components/ExpenseTable";
|
||||||
|
import PdfViewer from "../../components/PdfViewer";
|
||||||
|
import PdfActions from "../ApplicationView/PdfActions";
|
||||||
|
|
||||||
|
function Input({
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
handleChange,
|
||||||
|
handleBlur,
|
||||||
|
setFieldValue,
|
||||||
|
formFeilds,
|
||||||
|
}) {
|
||||||
|
const applicationId = useParams().applicationId;
|
||||||
|
|
||||||
|
const [showMiniFrom, setShowMiniForm] = useState(false);
|
||||||
|
const [expensesEditValues, setExpensesEditValues] = useState(null);
|
||||||
|
const [pdfIsVisible, setPdfIsVisible] = useState(false);
|
||||||
|
const [fileUrl, setFileUrl] = useState(null);
|
||||||
|
|
||||||
|
return formFeilds.map((section, sectionIndex) => {
|
||||||
|
if (
|
||||||
|
section?.parent?.name &&
|
||||||
|
!section?.parent?.values?.includes(values[section?.parent?.name])
|
||||||
|
) {
|
||||||
|
section.fields.forEach((formFeild) => {
|
||||||
|
if (typeof values[formFeild?.name] === "boolean") {
|
||||||
|
values[formFeild.name] = false;
|
||||||
|
} else {
|
||||||
|
values[formFeild.name] = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sectionIndex}
|
||||||
|
className="space-y-4 bg-white p-6 rounded-lg shadow-md min-w-fit border-t-4 border-red-700 mb-4"
|
||||||
|
>
|
||||||
|
<h3 className="text-xl font-semibold mt-2 mb-4">{section.label}</h3>
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
section.label === "Expense Details"
|
||||||
|
? "grid grid-cols-1" // Apply single column grid when the label is "Expense Details"
|
||||||
|
: section.label === "Travel Polciy Report"
|
||||||
|
? "grid grid-cols-2" // Apply two-column grid when the label is "Travel Polciy Report"
|
||||||
|
: "grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3" // Apply multi-column grid for other labels
|
||||||
|
} gap-4`}
|
||||||
|
>
|
||||||
|
{section.fields.map((formFeild) => {
|
||||||
|
if (formFeild?.parent?.name) {
|
||||||
|
if (values[formFeild?.parent?.name] === false) {
|
||||||
|
typeof values[formFeild?.name] === "boolean"
|
||||||
|
? (values[formFeild?.name] = false)
|
||||||
|
: (values[formFeild?.name] = "");
|
||||||
|
return null;
|
||||||
|
} else if (
|
||||||
|
typeof values[formFeild?.parent?.name] === "string" &&
|
||||||
|
!formFeild?.parent?.values.includes(
|
||||||
|
values[formFeild?.parent?.name]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
typeof values[formFeild?.name] === "boolean"
|
||||||
|
? (values[formFeild?.name] = false)
|
||||||
|
: (values[formFeild?.name] = "");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (formFeild.type) {
|
||||||
|
case "dropdown":
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={formFeild.name}
|
||||||
|
className="space-y-1 bg-slate-50 p-3 rounded-md"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor={formFeild.name}
|
||||||
|
className="block font-medium"
|
||||||
|
>
|
||||||
|
{formFeild.label}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name={formFeild.name}
|
||||||
|
id={formFeild.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
value={values[formFeild.name] || ""}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-700 transition duration-300 ease-in-out"
|
||||||
|
disabled={formFeild?.disabled}
|
||||||
|
>
|
||||||
|
<option value="" label="Select option" />
|
||||||
|
{formFeild.options[values[formFeild.depend] || ""].map(
|
||||||
|
(option) => (
|
||||||
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
label={option.label}
|
||||||
|
className="text-black"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<p className="text-red-500 text-sm">
|
||||||
|
{errors[formFeild.name] &&
|
||||||
|
touched[formFeild.name] &&
|
||||||
|
errors[formFeild.name]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "checkbox":
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={formFeild.name}
|
||||||
|
className="space-y-1 bg-slate-50 p-3 rounded-md"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor={formFeild.name}
|
||||||
|
className="inline-flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name={formFeild.name}
|
||||||
|
id={formFeild.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
checked={values[formFeild.name] || false}
|
||||||
|
className="h-4 w-4 border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-red-700"
|
||||||
|
disabled={formFeild?.disabled}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{formFeild.label}</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-red-500 text-sm">
|
||||||
|
{errors[formFeild.name] &&
|
||||||
|
touched[formFeild.name] &&
|
||||||
|
errors[formFeild.name]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "textarea":
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={formFeild.name}
|
||||||
|
className="space-y-1 bg-slate-50 p-3 rounded-md"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor={formFeild.name}
|
||||||
|
className="block font-medium"
|
||||||
|
>
|
||||||
|
{formFeild.label}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
name={formFeild.name}
|
||||||
|
id={formFeild.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
value={values[formFeild.name] || ""}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md max-h-32 min-h-20 focus:outline-none focus:ring-2 focus:ring-red-700 transition duration-300 ease-in-out"
|
||||||
|
disabled={formFeild?.disabled}
|
||||||
|
/>
|
||||||
|
<p className="text-red-500 text-sm">
|
||||||
|
{errors[formFeild.name] &&
|
||||||
|
touched[formFeild.name] &&
|
||||||
|
errors[formFeild.name]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "file":
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={formFeild.name}
|
||||||
|
className="space-y-1 bg-slate-50 p-3 rounded-md"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor={formFeild.name}
|
||||||
|
className="block font-medium"
|
||||||
|
>
|
||||||
|
{formFeild.label}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{formFeild?.disabled ? (
|
||||||
|
values[formFeild.name] === "" ? (
|
||||||
|
<p className="pt-2">No File Submitted</p>
|
||||||
|
) : (
|
||||||
|
<PdfActions
|
||||||
|
applicationId={applicationId}
|
||||||
|
fileName={formFeild.name}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name={formFeild.name}
|
||||||
|
id={formFeild.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFieldValue(formFeild.name, e.target.files[0]);
|
||||||
|
}}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className="w-full bg-white px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-700 transition duration-300 ease-in-out"
|
||||||
|
/>
|
||||||
|
<p className="text-red-500 text-sm">
|
||||||
|
{errors[formFeild.name] &&
|
||||||
|
touched[formFeild.name] &&
|
||||||
|
errors[formFeild.name]}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "miniForm":
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={formFeild.name}
|
||||||
|
className="space-y-4 bg-slate-50 p-6 rounded-md w-full"
|
||||||
|
>
|
||||||
|
{pdfIsVisible && (
|
||||||
|
<PdfViewer
|
||||||
|
fileUrl={fileUrl}
|
||||||
|
setIsModalOpen={setPdfIsVisible}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Label and Add Expense Button */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-4">
|
||||||
|
<label
|
||||||
|
htmlFor={formFeild.name}
|
||||||
|
className="block text-lg font-medium text-gray-800 mb-3 sm:mb-0 sm:w-1/2"
|
||||||
|
>
|
||||||
|
{`${formFeild.label}: ₹${values[formFeild.name]
|
||||||
|
?.reduce(
|
||||||
|
(total, rec) =>
|
||||||
|
total + parseFloat(rec.expenseAmount || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
.toFixed(2)}`}
|
||||||
|
{/* {values[formFeild.name]
|
||||||
|
?.reduce(
|
||||||
|
(total, rec) =>
|
||||||
|
total + parseFloat(rec.expenseAmount || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
.toFixed(2) > 10000 && (
|
||||||
|
<p className="text-red-600">Warning: Limit Exceded</p>
|
||||||
|
)} */}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!formFeild?.disabled &&
|
||||||
|
(values[formFeild.name]?.length < 10 ? (
|
||||||
|
<div className="flex-shrink-0 mt-4 sm:mt-0 sm:w-auto">
|
||||||
|
<button
|
||||||
|
className="bg-red-700 text-white font-semibold py-3 px-8 rounded-lg shadow-md transform transition duration-300 hover:bg-red-800 hover:scale-105 active:scale-95"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {setShowMiniForm(true); setExpensesEditValues(null)}}
|
||||||
|
>
|
||||||
|
Add Expense
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h3 className="block text-lg font-medium text-gray-800 mb-3 sm:mb-0 sm:w-1/2">
|
||||||
|
Cannot add more than 10 expenses
|
||||||
|
</h3>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expense Form */}
|
||||||
|
{showMiniFrom && !formFeild?.disabled && (
|
||||||
|
<ExpenseForm
|
||||||
|
onClose={() => setShowMiniForm(false)}
|
||||||
|
setExpenses={(newExpenses) =>
|
||||||
|
setFieldValue(formFeild.name, [
|
||||||
|
...values[formFeild.name],
|
||||||
|
newExpenses,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
editExpense={(editedExpense) => {
|
||||||
|
setFieldValue(
|
||||||
|
formFeild.name,
|
||||||
|
values[formFeild.name].map((expense) =>
|
||||||
|
expense === expensesEditValues
|
||||||
|
? editedExpense
|
||||||
|
: expense
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
expenses={expensesEditValues}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
<p className="text-red-500 text-sm mt-2">
|
||||||
|
{errors[formFeild.name] &&
|
||||||
|
touched[formFeild.name] &&
|
||||||
|
errors[formFeild.name]}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Display Expense Table */}
|
||||||
|
{values[formFeild.name]?.length > 0 && (
|
||||||
|
<div className="mt-6 w-full overflow-x-auto">
|
||||||
|
<ExpenseTable
|
||||||
|
expenses={values[formFeild.name]}
|
||||||
|
setPdfIsVisible={setPdfIsVisible}
|
||||||
|
setFileUrl={setFileUrl}
|
||||||
|
deleteExpense={(expense) =>
|
||||||
|
setFieldValue(
|
||||||
|
formFeild.name,
|
||||||
|
values[formFeild.name]?.filter(
|
||||||
|
(toDel) => toDel !== expense
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
editStatus={(expense, status) =>{
|
||||||
|
setFieldValue(
|
||||||
|
formFeild.name,
|
||||||
|
values[formFeild.name]?.map((toEdit) =>
|
||||||
|
toEdit === expense
|
||||||
|
? { ...toEdit, proofStatus: status }
|
||||||
|
: toEdit
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
editExpense={(expenseValues) => {setShowMiniForm(true); setExpensesEditValues(expenseValues)}}
|
||||||
|
disabled={formFeild?.disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={formFeild.name}
|
||||||
|
className="space-y-1 bg-slate-50 p-3 rounded-md"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor={formFeild.name}
|
||||||
|
className="block font-medium"
|
||||||
|
>
|
||||||
|
{formFeild.label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={formFeild.type}
|
||||||
|
name={formFeild.name}
|
||||||
|
id={formFeild.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
value={values[formFeild.name] || ""}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-700 transition duration-300 ease-in-out"
|
||||||
|
disabled={formFeild?.disabled}
|
||||||
|
max={formFeild?.max}
|
||||||
|
min={formFeild?.min}
|
||||||
|
/>
|
||||||
|
<p className="text-red-500 text-sm">
|
||||||
|
{errors[formFeild.name] &&
|
||||||
|
touched[formFeild.name] &&
|
||||||
|
errors[formFeild.name]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Input;
|
||||||
243
frontend/src/pages/ApplicationForm/components/ExpenseForm.jsx
Normal file
243
frontend/src/pages/ApplicationForm/components/ExpenseForm.jsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import Modal from "../../../components/Modal/Modal"; // Ensure your Modal is correctly imported
|
||||||
|
|
||||||
|
// Validation function (manually written)
|
||||||
|
const validateForm = (values) => {
|
||||||
|
const errors = {};
|
||||||
|
|
||||||
|
// Validate Expense Category
|
||||||
|
if (!values.expenseName) {
|
||||||
|
errors.expenseName = "Expense Category is required";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Expense Name
|
||||||
|
if (!values.expenseDetails) {
|
||||||
|
errors.expenseDetails = "Expense Name is required";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Expense Amount
|
||||||
|
if (!values.expenseAmount) {
|
||||||
|
errors.expenseAmount = "Expense Amount is required";
|
||||||
|
} else if (values.expenseAmount <= 0) {
|
||||||
|
errors.expenseAmount = "Amount must be positive";
|
||||||
|
} else if (values.expenseAmount < 1) {
|
||||||
|
errors.expenseAmount = "Amount must be at least 1";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Expense Proof
|
||||||
|
if (!values.expenseProof) {
|
||||||
|
errors.expenseProof = "Expense Proof is required";
|
||||||
|
} else {
|
||||||
|
// Validate file size and type
|
||||||
|
if (values.expenseProof.size > 1024 * 1024) {
|
||||||
|
errors.expenseProof = "File size too large (max 1MB)";
|
||||||
|
} else if (
|
||||||
|
!["image/jpeg", "image/png", "application/pdf"].includes(values.expenseProof.type)
|
||||||
|
) {
|
||||||
|
errors.expenseProof = "Invalid file type. Only JPEG, PNG, and PDF are allowed.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExpenseForm = ({ onClose, setExpenses, editExpense, expenses = null }) => {
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const [values, setValues] = useState({
|
||||||
|
expenseName: "",
|
||||||
|
expenseDetails: "",
|
||||||
|
expenseAmount: "",
|
||||||
|
expenseProof: null,
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [touched, setTouched] = useState({
|
||||||
|
expenseName: false,
|
||||||
|
expenseDetails: false,
|
||||||
|
expenseAmount: false,
|
||||||
|
expenseProof: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expenses) {
|
||||||
|
// If the expenses object contains a File, set it to the input
|
||||||
|
if (expenses.expenseProof && expenses.expenseProof instanceof File) {
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.items.add(expenses.expenseProof); // Add the file from expenses to DataTransfer
|
||||||
|
fileInputRef.current.files = dataTransfer.files; // Set files to the input
|
||||||
|
}
|
||||||
|
|
||||||
|
setValues(expenses); // Set the rest of the form data
|
||||||
|
}
|
||||||
|
}, [expenses]);
|
||||||
|
|
||||||
|
// Handle form input changes with error checking on each keystroke
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setValues((prevValues) => {
|
||||||
|
const updatedValues = { ...prevValues, [name]: value };
|
||||||
|
|
||||||
|
// Validate the field on every change
|
||||||
|
const validationErrors = validateForm(updatedValues);
|
||||||
|
setErrors(validationErrors);
|
||||||
|
|
||||||
|
return updatedValues;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle file input change
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
setValues((prevValues) => {
|
||||||
|
const updatedValues = { ...prevValues, expenseProof: file };
|
||||||
|
|
||||||
|
// Validate the file field on change
|
||||||
|
const validationErrors = validateForm(updatedValues);
|
||||||
|
setErrors(validationErrors);
|
||||||
|
|
||||||
|
return updatedValues;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle blur (mark field as touched)
|
||||||
|
const handleBlur = (e) => {
|
||||||
|
const { name } = e.target;
|
||||||
|
setTouched((prevTouched) => ({
|
||||||
|
...prevTouched,
|
||||||
|
[name]: true,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// Validate form before submission
|
||||||
|
const validationErrors = validateForm(values);
|
||||||
|
setErrors(validationErrors);
|
||||||
|
|
||||||
|
// If no errors, proceed with submission
|
||||||
|
if (Object.keys(validationErrors).length === 0) {
|
||||||
|
if (expenses) {
|
||||||
|
editExpense({
|
||||||
|
expenseId: expenses.expenseId,
|
||||||
|
expenseName: values.expenseName,
|
||||||
|
expenseDetails: values.expenseDetails,
|
||||||
|
expenseAmount: values.expenseAmount,
|
||||||
|
expenseProof: values.expenseProof,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setExpenses({
|
||||||
|
expenseId: crypto.randomUUID(),
|
||||||
|
expenseName: values.expenseName,
|
||||||
|
expenseDetails: values.expenseDetails,
|
||||||
|
expenseAmount: values.expenseAmount,
|
||||||
|
expenseProof: values.expenseProof,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onClose(); // Close the modal after submission
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Expense Category */}
|
||||||
|
<div className="space-y-1 bg-slate-50 p-3 rounded-md">
|
||||||
|
<label htmlFor="expenseName" className="block font-medium">
|
||||||
|
Expense Name
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="expenseName"
|
||||||
|
id="expenseName"
|
||||||
|
value={values.expenseName}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||||
|
>
|
||||||
|
<option value="">Select Category</option>
|
||||||
|
<option value="TRAVEL">Travel</option>
|
||||||
|
<option value="LODGING">Lodging</option>
|
||||||
|
<option value="BOARDING">Boarding</option>
|
||||||
|
<option value="LOCAL_CONVEYANCE">Local Conveyance</option>
|
||||||
|
<option value="TRANSPORTATION">Transportation</option>
|
||||||
|
<option value="REGISTRATION">Registration</option>
|
||||||
|
<option value="MISCELLANEOUS">Miscellaneous</option>
|
||||||
|
</select>
|
||||||
|
{errors.expenseName && (
|
||||||
|
<p className="text-red-500 text-sm">{errors.expenseName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expense Name */}
|
||||||
|
<div className="space-y-1 bg-slate-50 p-3 rounded-md">
|
||||||
|
<label htmlFor="expenseDetails" className="block font-medium">
|
||||||
|
Expense Details
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="expenseDetails"
|
||||||
|
id="expenseDetails"
|
||||||
|
value={values.expenseDetails}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
{errors.expenseDetails && (
|
||||||
|
<p className="text-red-500 text-sm">{errors.expenseDetails}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expense Amount */}
|
||||||
|
<div className="space-y-1 bg-slate-50 p-3 rounded-md">
|
||||||
|
<label htmlFor="expenseAmount" className="block font-medium">
|
||||||
|
Expense Amount
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="expenseAmount"
|
||||||
|
id="expenseAmount"
|
||||||
|
value={values.expenseAmount}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
{errors.expenseAmount && (
|
||||||
|
<p className="text-red-500 text-sm">{errors.expenseAmount}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expense Proof (File Upload) */}
|
||||||
|
<div className="space-y-1 bg-slate-50 p-3 rounded-md">
|
||||||
|
<label htmlFor="expenseProof" className="block font-medium">
|
||||||
|
Expense Proof (Upload File)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
name="expenseProof"
|
||||||
|
id="expenseProof"
|
||||||
|
accept="application/pdf"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
{errors.expenseProof && (
|
||||||
|
<p className="text-red-500 text-sm">{errors.expenseProof}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div className="flex justify-center mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit} // Call handleSubmit manually
|
||||||
|
className="bg-blue-600 text-white py-2 px-6 rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{expenses ? "Update" : "Add"} Expense
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpenseForm;
|
||||||
277
frontend/src/pages/ApplicationForm/components/ExpenseTable.jsx
Normal file
277
frontend/src/pages/ApplicationForm/components/ExpenseTable.jsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTable } from "react-table";
|
||||||
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
MdDangerous,
|
||||||
|
MdDeleteOutline,
|
||||||
|
MdEdit,
|
||||||
|
MdVerified
|
||||||
|
} from "react-icons/md";
|
||||||
|
import PdfActions from "../../ApplicationView/PdfActions";
|
||||||
|
import { useParams, useRouteLoaderData } from "react-router-dom";
|
||||||
|
|
||||||
|
const ExpenseTable = ({
|
||||||
|
expenses,
|
||||||
|
setPdfIsVisible,
|
||||||
|
setFileUrl,
|
||||||
|
deleteExpense,
|
||||||
|
editExpense,
|
||||||
|
editStatus,
|
||||||
|
disabled,
|
||||||
|
}) => {
|
||||||
|
const applicationId = useParams().applicationId;
|
||||||
|
const applicationStatus = useParams().status;
|
||||||
|
const { role } =
|
||||||
|
useRouteLoaderData("Applicant-Root")?.data ||
|
||||||
|
useRouteLoaderData("Validator-Root")?.data;
|
||||||
|
|
||||||
|
// const handleExpenseAction = async (expense, action) => {
|
||||||
|
// try {
|
||||||
|
// await axios.put(
|
||||||
|
// `${import.meta.env.VITE_APP_API_URL}/validator/expenseAction`,
|
||||||
|
// { expense, action, applicationId },
|
||||||
|
// { withCredentials: true }
|
||||||
|
// );
|
||||||
|
// alert(`Proof ${action}`);
|
||||||
|
// window.location.reload();
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error("Error performing expense action:", error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
const columns = React.useMemo(() => {
|
||||||
|
// Common columns
|
||||||
|
const baseColumns = [
|
||||||
|
{
|
||||||
|
Header: "Expense Name",
|
||||||
|
accessor: "expenseName",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "Expense Details",
|
||||||
|
accessor: "expenseDetails",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "Amount",
|
||||||
|
accessor: "expenseAmount",
|
||||||
|
Cell: ({ value }) => `₹${value}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "Expense Proof",
|
||||||
|
accessor: "expenseProof",
|
||||||
|
Cell: ({ value, row }) => {
|
||||||
|
if (applicationId) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-72 m-auto">
|
||||||
|
<PdfActions
|
||||||
|
fileName={"expenseProof" + row.id}
|
||||||
|
applicationId={applicationId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (value && value.name) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setPdfIsVisible(true);
|
||||||
|
setFileUrl(URL.createObjectURL(value));
|
||||||
|
}}
|
||||||
|
className="text-blue-600 hover:text-blue-700 focus:outline-none"
|
||||||
|
>
|
||||||
|
View Document
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return "No Document";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add the "Delete" column only if 'disabled' is false
|
||||||
|
if (role === "Applicant" && !disabled) {
|
||||||
|
baseColumns.push({
|
||||||
|
Header: "Actions",
|
||||||
|
accessor: "actions",
|
||||||
|
Cell: ({ row }) => (
|
||||||
|
row?.original?.proofStatus != "verified" ?(
|
||||||
|
<div className="flex justify-center space-x-7">
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteExpense(row.original)}
|
||||||
|
className="bg-red-600 text-white py-2 px-3 rounded-lg hover:bg-red-700 transition-colors focus:outline-none"
|
||||||
|
>
|
||||||
|
<MdDeleteOutline />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => editExpense(row.original)}
|
||||||
|
className="bg-blue-600 text-white py-2 px-3 rounded-lg hover:bg-blue-700 transition-colors focus:outline-none"
|
||||||
|
>
|
||||||
|
<MdEdit />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>) : <h1 className="text-green-600">Approved</h1>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === "Validator") {
|
||||||
|
baseColumns.push({
|
||||||
|
Header: "Approval",
|
||||||
|
accessor: "approval",
|
||||||
|
Cell: ({ row }) => {
|
||||||
|
const isVerified = row?.original?.proofStatus === "verified";
|
||||||
|
const isRejected = row?.original?.proofStatus === "rejected";
|
||||||
|
const status = isVerified ? "verified" : isRejected ? "rejected" : "pending";
|
||||||
|
const [hoverSide, setHoverSide] = useState(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-2">
|
||||||
|
<div className="relative flex items-center w-36 sm:w-48 cursor-pointer my-5 group">
|
||||||
|
{/* Status indicator text */}
|
||||||
|
<div className="absolute -top-5 left-0 w-full flex justify-between text-xs font-medium">
|
||||||
|
<span className={`${status === "verified" ? "text-green-600 font-bold" : "text-gray-500"}`}>Approved</span>
|
||||||
|
<span className={`${status === "pending" ? "text-gray-600 font-bold" : "text-gray-500"}`}>Pending</span>
|
||||||
|
<span className={`${status === "rejected" ? "text-red-600 font-bold" : "text-gray-500"}`}>Rejected</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Track background with hover effect */}
|
||||||
|
<div className={`w-full h-8 sm:h-10 rounded-full bg-gradient-to-r from-green-600 via-gray-300 to-red-600 p-1 group-hover:shadow-md transition-all duration-300 relative overflow-hidden`}>
|
||||||
|
{/* Hover indicators */}
|
||||||
|
{hoverSide === 'left' && (
|
||||||
|
<div className="absolute inset-y-0 left-0 w-1/2 bg-green-300 bg-opacity-30 rounded-l-full pointer-events-none z-0"></div>
|
||||||
|
)}
|
||||||
|
{hoverSide === 'right' && (
|
||||||
|
<div className="absolute inset-y-0 right-0 w-1/2 bg-red-300 bg-opacity-30 rounded-r-full pointer-events-none z-0"></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sliding knob */}
|
||||||
|
<div
|
||||||
|
className={`absolute h-6 sm:h-8 w-10 sm:w-12 bg-white rounded-full shadow-lg transition-all duration-300 flex items-center justify-center transform ${
|
||||||
|
status === "verified" ? "left-1" :
|
||||||
|
status === "rejected" ? "right-1" :
|
||||||
|
"left-1/2 -translate-x-1/2"
|
||||||
|
} group-hover:shadow-xl z-10`}
|
||||||
|
>
|
||||||
|
{status === "verified" && <MdVerified className="text-green-600" size={18} />}
|
||||||
|
{status === "rejected" && <MdDangerous className="text-red-600" size={18} />}
|
||||||
|
{status === "pending" && <span className="text-sm text-gray-600 font-medium">?</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clickable buttons overlaid on top */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => editStatus(row.original, "verified")}
|
||||||
|
onMouseEnter={() => setHoverSide('left')}
|
||||||
|
onMouseLeave={() => setHoverSide(null)}
|
||||||
|
className="absolute left-0 w-1/2 h-full opacity-0 z-20"
|
||||||
|
aria-label="Approve"
|
||||||
|
disabled={applicationStatus != "pending"}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => editStatus(row.original, "rejected")}
|
||||||
|
onMouseEnter={() => setHoverSide('right')}
|
||||||
|
onMouseLeave={() => setHoverSide(null)}
|
||||||
|
className="absolute right-0 w-1/2 h-full opacity-0 z-20"
|
||||||
|
aria-label="Reject"
|
||||||
|
disabled={applicationStatus != "pending"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status text */}
|
||||||
|
<p className={`text-xs ${
|
||||||
|
status === "verified" ? "text-green-600 font-medium" :
|
||||||
|
status === "rejected" ? "text-red-600 font-medium" :
|
||||||
|
"text-gray-500"
|
||||||
|
}`}>
|
||||||
|
{status === "verified" ? "Expense Approved" :
|
||||||
|
status === "rejected" ? "Expense Rejected" :
|
||||||
|
"Click to change status"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseColumns;
|
||||||
|
}, [deleteExpense, setPdfIsVisible, setFileUrl, disabled]);
|
||||||
|
|
||||||
|
// Using the useTable hook to create the table instance
|
||||||
|
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
||||||
|
useTable({
|
||||||
|
columns,
|
||||||
|
data: expenses || [], // Data passed to the table
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full mx-auto bg-white p-6 rounded-lg shadow-md hover:shadow-lg">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||||
|
Expense Breakdown
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Wrapping the table inside a scrollable div for responsiveness */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table
|
||||||
|
{...getTableProps()}
|
||||||
|
className="min-w-full bg-white border border-gray-300 table-auto"
|
||||||
|
>
|
||||||
|
{/* Table header */}
|
||||||
|
<thead className="bg-gray-100 sticky top-0 z-10">
|
||||||
|
{headerGroups.map((headerGroup) => {
|
||||||
|
const { key, ...restHeaderProps } =
|
||||||
|
headerGroup.getHeaderGroupProps(); // Destructure `key` here
|
||||||
|
return (
|
||||||
|
<tr key={key} {...restHeaderProps}>
|
||||||
|
{headerGroup.headers.map((column) => {
|
||||||
|
const { key, ...restColumnProps } = column.getHeaderProps(); // Destructure `key` here
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={column.id} // Explicitly add key for th
|
||||||
|
{...restColumnProps}
|
||||||
|
className="px-3 py-2 text-center text-sm font-bold text-gray-600 border-b border-gray-300"
|
||||||
|
>
|
||||||
|
{column.render("Header")}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
{/* Table body */}
|
||||||
|
<tbody {...getTableBodyProps()} className="text-sm text-gray-700">
|
||||||
|
{rows.map((row) => {
|
||||||
|
prepareRow(row);
|
||||||
|
const { key, ...restRowProps } = row.getRowProps(); // Destructure `key` here
|
||||||
|
return (
|
||||||
|
<tr key={key} {...restRowProps} className="hover:bg-gray-50">
|
||||||
|
{row.cells.map((cell) => {
|
||||||
|
const { key, ...restCellProps } = cell.getCellProps(); // Destructure `key` here
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={cell.column.id} // Explicitly add key for td
|
||||||
|
{...restCellProps}
|
||||||
|
className="px-3 py-2 border-t border-b border-gray-300"
|
||||||
|
>
|
||||||
|
{cell.render("Cell")}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpenseTable;
|
||||||
127
frontend/src/pages/ApplicationView/AcceptChoice.jsx
Normal file
127
frontend/src/pages/ApplicationView/AcceptChoice.jsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Modal from "../../components/Modal/Modal";
|
||||||
|
|
||||||
|
function AcceptChoice({
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
designation,
|
||||||
|
applicantDesignation,
|
||||||
|
}) {
|
||||||
|
const handleSubmit = (toVC = false) => {
|
||||||
|
onSubmit(toVC);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose}>
|
||||||
|
<div className="bg-white rounded-lg p-6 shadow-lg mx-auto">
|
||||||
|
<h2 className="text-2xl font-semibold text-red-700 mb-4">
|
||||||
|
Confirm Application Approval
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
{(() => {
|
||||||
|
switch (designation) {
|
||||||
|
case "FACULTY":
|
||||||
|
return "By approving, you will forward this application to the Head of Department (HOD).";
|
||||||
|
case "HOD":
|
||||||
|
return "By approving, you will forward this application to the Head of Institute (HOI).";
|
||||||
|
case "HOI":
|
||||||
|
if (applicantDesignation === "STUDENT") {
|
||||||
|
return "By approving, you will forward this application to Accounts.";
|
||||||
|
} else {
|
||||||
|
return "By approving, you can forward this application to either the Vice Chancellor (VC) or Accounts.";
|
||||||
|
}
|
||||||
|
case "VC":
|
||||||
|
return "By approving, you will forward this application to Accounts.";
|
||||||
|
case "ACCOUNTS":
|
||||||
|
return "By approving, you confirm that the given expenses will be paid by the institute.";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 justify-center">
|
||||||
|
{/* Cancel Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 focus:outline-none"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
switch (designation) {
|
||||||
|
case "FACULTY":
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 bg-green-700 text-white rounded-lg hover:bg-green-800 focus:outline-none"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
case "HOD":
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 bg-green-700 text-white rounded-lg hover:bg-green-800 focus:outline-none"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
case "HOI":
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{applicantDesignation !== "STUDENT" && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSubmit(true)}
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 bg-blue-700 text-white rounded-lg hover:bg-blue-800 focus:outline-none"
|
||||||
|
>
|
||||||
|
Forward to VC
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleSubmit(false)}
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 bg-green-700 text-white rounded-lg hover:bg-green-800 focus:outline-none"
|
||||||
|
>
|
||||||
|
Forward to Accounts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "VC":
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 bg-green-700 text-white rounded-lg hover:bg-green-800 focus:outline-none"
|
||||||
|
>
|
||||||
|
Forward to Accounts
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
case "ACCOUNTS":
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 bg-green-700 text-white rounded-lg hover:bg-green-700 focus:outline-none"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AcceptChoice;
|
||||||
246
frontend/src/pages/ApplicationView/ApplicationView.jsx
Normal file
246
frontend/src/pages/ApplicationView/ApplicationView.jsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
useNavigate,
|
||||||
|
useParams,
|
||||||
|
useRouteLoaderData,
|
||||||
|
useSubmit,
|
||||||
|
} from "react-router-dom";
|
||||||
|
import ValidationStatus from "./ValidationStatus";
|
||||||
|
import Form from "../ApplicationForm/Form";
|
||||||
|
import RejectionFeedback from "./RejectionFeedback";
|
||||||
|
import { TbLoader3 } from "react-icons/tb";
|
||||||
|
import AcceptChoice from "./AcceptChoice";
|
||||||
|
import { FaCopy } from "react-icons/fa";
|
||||||
|
|
||||||
|
function ApplicationView() {
|
||||||
|
const { role, user } =
|
||||||
|
useRouteLoaderData("Applicant-Root")?.data ||
|
||||||
|
useRouteLoaderData("Validator-Root")?.data;
|
||||||
|
const submit = useSubmit();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [applicationDisplay, setApplicationDisplay] = useState(null);
|
||||||
|
const [rejectionFeedbackPopUp, setRejectionFeedbackPopUp] = useState(false);
|
||||||
|
const [acceptChoicePopUp, setAcceptChoicePopUp] = useState(false);
|
||||||
|
const [copySuccess, setCopySuccess] = useState(false);
|
||||||
|
|
||||||
|
const [formValues, setFormValues] = useState({});
|
||||||
|
|
||||||
|
const applicationId = useParams().applicationId;
|
||||||
|
const statusParam = useParams().status;
|
||||||
|
|
||||||
|
const getFullApplication = async (applicationId) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_API_URL
|
||||||
|
}/general/getApplicationData/${applicationId}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch application data: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fullApplication = await response.json();
|
||||||
|
setApplicationDisplay(fullApplication?.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching application data:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (
|
||||||
|
applicationId,
|
||||||
|
action,
|
||||||
|
rejectionFeedback = "",
|
||||||
|
toVC = false,
|
||||||
|
resubmission = false
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("applicationId", applicationId);
|
||||||
|
formData.append("action", action);
|
||||||
|
formData.append("rejectionFeedback", rejectionFeedback);
|
||||||
|
formData.append("toVC", toVC);
|
||||||
|
formData.append("resubmission", resubmission);
|
||||||
|
|
||||||
|
if (formValues && formValues?.expenses) {
|
||||||
|
formData.append("expenses", JSON.stringify(formValues.expenses));
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(formData, {
|
||||||
|
method: "PUT",
|
||||||
|
encType: "multipart/form-data", // Specify the encoding type
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during submit:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigation for status change
|
||||||
|
let currentStatus = applicationDisplay?.currentStatus?.toLowerCase();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getFullApplication(applicationId);
|
||||||
|
}, [applicationId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
(statusParam !== currentStatus && currentStatus) ||
|
||||||
|
(applicationId !== applicationDisplay?.applicationId &&
|
||||||
|
applicationDisplay?.applicationId)
|
||||||
|
) {
|
||||||
|
const location = window.location.pathname;
|
||||||
|
const newPath = location.split("/").slice(0, -2).join("/");
|
||||||
|
navigate(
|
||||||
|
`${newPath}/${currentStatus}/${applicationDisplay?.applicationId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [statusParam, currentStatus, applicationDisplay]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col justify-center items-center h-full animate-pulse pb-[10%]">
|
||||||
|
<TbLoader3 className="animate-spin text-xl size-24 text-red-700" />
|
||||||
|
<p className="mt-2">Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = applicationDisplay?.formData?.eventName;
|
||||||
|
|
||||||
|
if (!applicationDisplay) return null;
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
navigator.clipboard.writeText(applicationId)
|
||||||
|
.then(() => {
|
||||||
|
setCopySuccess(true);
|
||||||
|
setTimeout(() => setCopySuccess(false), 2000);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Failed to copy application ID: ", err);
|
||||||
|
alert("Failed to copy application ID. Please try again.");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if this is a Travel Intimation Form
|
||||||
|
const isTravelIntimationForm = applicationDisplay?.formData?.formName === "Travel Intimation Form";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-min bg-white shadow rounded-lg p-2 sm:p-4 md:p-6 m-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start mb-6 gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-extrabold text-gray-800">{title}</h1>
|
||||||
|
</div>
|
||||||
|
{isTravelIntimationForm && (
|
||||||
|
<button
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
className={`flex items-center ${copySuccess ? 'bg-green-600' : 'bg-red-700 hover:bg-red-800'} text-white font-medium py-2 px-4 rounded-lg shadow-md transition duration-300 transform ${copySuccess ? '' : 'hover:scale-110'} self-start sm:self-auto`}
|
||||||
|
title="Copy Application ID"
|
||||||
|
>
|
||||||
|
{copySuccess ? (
|
||||||
|
<>
|
||||||
|
<span className="mr-2">✓</span>
|
||||||
|
<span className="hidden sm:inline">Copied!</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaCopy className="mr-2" />
|
||||||
|
<span className="hidden sm:inline">Copy ID</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ValidationStatus
|
||||||
|
validations={{
|
||||||
|
facultyValidation: applicationDisplay?.facultyValidation,
|
||||||
|
hodValidation: applicationDisplay?.hodValidation,
|
||||||
|
hoiValidation: applicationDisplay?.hoiValidation,
|
||||||
|
vcValidation: applicationDisplay?.vcValidation,
|
||||||
|
accountsValidation: applicationDisplay?.accountsValidation,
|
||||||
|
}}
|
||||||
|
rejectionFeedback={applicationDisplay?.rejectionFeedback}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
prefilledData={applicationDisplay?.formData}
|
||||||
|
applicantDesignation={applicationDisplay?.applicant?.designation}
|
||||||
|
resubmission={applicationDisplay?.resubmission || false}
|
||||||
|
onValuesChange={(values) => setFormValues(values)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{rejectionFeedbackPopUp && (
|
||||||
|
<RejectionFeedback
|
||||||
|
onClose={() => setRejectionFeedbackPopUp(false)}
|
||||||
|
onSubmit={(rejectionFeedback, resubmission) =>
|
||||||
|
handleSubmit(
|
||||||
|
applicationDisplay?.applicationId,
|
||||||
|
"rejected",
|
||||||
|
rejectionFeedback,
|
||||||
|
false,
|
||||||
|
resubmission
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{acceptChoicePopUp && (
|
||||||
|
<AcceptChoice
|
||||||
|
onClose={() => setAcceptChoicePopUp(false)}
|
||||||
|
onSubmit={(toVC) =>
|
||||||
|
handleSubmit(applicationDisplay?.applicationId, "accepted", "", toVC, false)
|
||||||
|
}
|
||||||
|
designation={user.designation}
|
||||||
|
applicantDesignation={applicationDisplay?.applicant?.designation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center my-4 gap-2 mx-2">
|
||||||
|
{role === "Validator" && currentStatus === "pending" && (
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAcceptChoicePopUp(true)}
|
||||||
|
className="bg-green-700 text-white font-semibold text-sm sm:text-sm md:text-lg px-4 py-2 rounded-md hover:bg-green-800 focus:outline-double transition duration-200 hover:scale-110 hover:animate-spin"
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRejectionFeedbackPopUp(true)}
|
||||||
|
className="bg-red-700 text-white font-semibold text-sm sm:text-sm md:text-lg px-4 py-2 rounded-md hover:bg-red-800 focus:outline-double transition duration-200 hover:scale-110 hover:animate-spin"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const location = window.location.pathname;
|
||||||
|
const newPath = location.split("/").slice(0, -1).join("/");
|
||||||
|
navigate(newPath);
|
||||||
|
}}
|
||||||
|
className="bg-blue-700 text-white font-semibold text-sm sm:text-sm md:text-lg px-4 py-2 rounded-md hover:bg-blue-800 focus:outline-double transition duration-200 hover:scale-110"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApplicationView;
|
||||||
81
frontend/src/pages/ApplicationView/PdfActions.jsx
Normal file
81
frontend/src/pages/ApplicationView/PdfActions.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// PdfActions.js
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import Modal from "../../components/Modal/Modal.jsx";
|
||||||
|
import PdfViewer from "../../components/PdfViewer.jsx";
|
||||||
|
import { FaFileDownload } from "react-icons/fa";
|
||||||
|
|
||||||
|
function PdfActions({ fileName, applicationId }) {
|
||||||
|
const [fileUrl, setFileUrl] = useState(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const fetchFileBlob = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_API_URL
|
||||||
|
}/general/getFile/${applicationId}/${fileName}`,
|
||||||
|
{
|
||||||
|
responseType: "blob",
|
||||||
|
withCredentials: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.type !== "application/pdf") {
|
||||||
|
throw new Error("Invalid file format received.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([response.data], { type: "application/pdf" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setFileUrl(url);
|
||||||
|
|
||||||
|
return () => URL.revokeObjectURL(url); // Clean up URL when component unmounts
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching PDF:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleView = async () => {
|
||||||
|
if (!fileUrl) await fetchFileBlob(); // Only fetch if fileUrl is not set
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
if (!fileUrl) await fetchFileBlob(); // Only fetch if fileUrl is not set
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = fileUrl;
|
||||||
|
link.setAttribute("download", `${fileName}.pdf`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3 justify-center lg:justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hidden bg-gray-500 hover:bg-gray-700 text-white text-sm font-bold py-2 px-3 rounded transition-all duration-300 ease-in-out focus:outline-none focus:ring-2 focus:ring-gray-300 sm:block md:block"
|
||||||
|
onClick={handleView}
|
||||||
|
>
|
||||||
|
View PDF
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bg-gray-500 hover:bg-gray-700 text-white text-sm font-bold py-2 px-3 rounded transition-all duration-300 ease-in-out focus:outline-none focus:ring-2 focus:ring-gray-300 flex gap-2 items-center"
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
<FaFileDownload className="block sm:hidden md:hidden"/>
|
||||||
|
<span className="hidden sm:block md:block">Download PDF</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Modal to view PDF */}
|
||||||
|
{isModalOpen && fileUrl && (
|
||||||
|
<PdfViewer fileUrl={fileUrl} setIsModalOpen={setIsModalOpen} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PdfActions;
|
||||||
58
frontend/src/pages/ApplicationView/RejectionFeedback.jsx
Normal file
58
frontend/src/pages/ApplicationView/RejectionFeedback.jsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import Modal from '../../components/Modal/Modal';
|
||||||
|
import { MdOutlineSettingsInputHdmi } from 'react-icons/md';
|
||||||
|
|
||||||
|
function RejectionFeedback({ onClose, onSubmit }) {
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setReason(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e, resubmission = false) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(reason, resubmission);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose}>
|
||||||
|
<div className="bg-white rounded-lg p-1 shadow-lg">
|
||||||
|
<h2 className="text-2xl font-semibold text-red-700 mb-4">Confirm Application Rejection</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Please provide a reason for rejecting the application.<br/> This will help the applicant to improve future applications.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="space-y-4">
|
||||||
|
<textarea
|
||||||
|
className="w-full p-4 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-700"
|
||||||
|
placeholder="Enter the reason for rejection"
|
||||||
|
rows="4"
|
||||||
|
value={reason}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleSubmit(e, false)}
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 bg-red-700 text-white rounded-lg hover:bg-red-800 focus:outline-none"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleSubmit(e, true)}
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 bg-green-700 text-white rounded-lg hover:bg-green-800 focus:outline-none"
|
||||||
|
>
|
||||||
|
Allow Resubmission
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RejectionFeedback;
|
||||||
60
frontend/src/pages/ApplicationView/ValidationStatus.jsx
Normal file
60
frontend/src/pages/ApplicationView/ValidationStatus.jsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { MdWarning } from "react-icons/md";
|
||||||
|
|
||||||
|
function ValidationStatus({ validations, rejectionFeedback }) {
|
||||||
|
|
||||||
|
const roles = [
|
||||||
|
{ name: "FACULTY", status: validations.facultyValidation },
|
||||||
|
{ name: "HOD", status: validations.hodValidation },
|
||||||
|
{ name: "HOI", status: validations.hoiValidation },
|
||||||
|
{ name: "VC", status: validations.vcValidation },
|
||||||
|
{ name: "ACCOUNTS", status: validations.accountsValidation },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case "ACCEPTED":
|
||||||
|
return "bg-green-300";
|
||||||
|
case "REJECTED":
|
||||||
|
return "bg-red-300";
|
||||||
|
default:
|
||||||
|
return "bg-yellow-300";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="m-3">
|
||||||
|
<div className="flex flex-row justify-evenly">
|
||||||
|
{roles
|
||||||
|
.filter((role) => role.status !== null) // Exclude roles with null status
|
||||||
|
.map((role, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex flex-col gap-1 justify-center items-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`rounded-full w-10 h-10 ${getStatusColor(
|
||||||
|
role.status
|
||||||
|
)}`}
|
||||||
|
></div>
|
||||||
|
<p>{role.name}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rejectionFeedback && (
|
||||||
|
<div
|
||||||
|
className="mt-4 p-4 bg-red-100 border-l-4 border-red-500 text-red-700 rounded-lg shadow-md w-fit min-w-[30%]"
|
||||||
|
>
|
||||||
|
<div className="flex justify-start items-center gap-2">
|
||||||
|
<MdWarning className="w-6 h-6 text-red-500" />
|
||||||
|
<p className="font-semibold">Rejection Reason:</p>
|
||||||
|
</div>
|
||||||
|
<p>{rejectionFeedback}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ValidationStatus;
|
||||||
112
frontend/src/pages/Applications/Applications.jsx
Normal file
112
frontend/src/pages/Applications/Applications.jsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useParams, useRouteLoaderData } from "react-router-dom";
|
||||||
|
import ApplicationTable from "../Applications/components/ApplicationTable";
|
||||||
|
import Pagination from "../../components/Pagination";
|
||||||
|
import axios from "axios";
|
||||||
|
import ApplicationView from "../ApplicationView/ApplicationView";
|
||||||
|
import ApplicationsStatusDescription from "./components/ApplicationsStatusDescription";
|
||||||
|
import Search from "./components/Search";
|
||||||
|
import Modal from "../../components/Modal/Modal";
|
||||||
|
import Root from "../../components/DashboardRoot/Root";
|
||||||
|
import { TbLoader3 } from "react-icons/tb";
|
||||||
|
|
||||||
|
const Applications = () => {
|
||||||
|
const { role } =
|
||||||
|
useRouteLoaderData("Applicant-Root")?.data ||
|
||||||
|
useRouteLoaderData("Validator-Root")?.data;
|
||||||
|
const [numOfApplications, setNumOfApplications] = useState(0);
|
||||||
|
const [applications, setApplications] = useState([]);
|
||||||
|
const [applicantName, setApplicantName] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [applicationDisplay, setApplicationDisplay] = useState(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const itemsPerPage = 4;
|
||||||
|
const { status } = useParams();
|
||||||
|
|
||||||
|
// Reset currentPage when status changes
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
// Fetch applications based on status, pagination, and search criteria
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchApplications = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const skip = (currentPage - 1) * itemsPerPage;
|
||||||
|
const res = await axios.get(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_API_URL
|
||||||
|
}/general/getApplications/${status}?take=${itemsPerPage}&skip=${skip}${
|
||||||
|
applicantName
|
||||||
|
? `&sortBy=applicantName&sortValue=${applicantName}`
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
|
{ withCredentials: true }
|
||||||
|
);
|
||||||
|
setNumOfApplications(res.data.totalApplications);
|
||||||
|
setApplications(res.data.applications);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchApplications();
|
||||||
|
}, [status, currentPage, applicantName]);
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = useCallback((selection) => {
|
||||||
|
setApplicantName(selection); // Update search criteria only when selection is finalized
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderTable = () =>
|
||||||
|
applications.length > 0 ? (
|
||||||
|
<ApplicationTable
|
||||||
|
title={`${
|
||||||
|
status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()
|
||||||
|
} Applications`}
|
||||||
|
applications={applications}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-600">
|
||||||
|
No {status.toLowerCase()} applications found.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col justify-center items-center h-full animate-pulse pb-[10%]">
|
||||||
|
<TbLoader3 className="animate-spin text-xl size-24 text-red-700" />
|
||||||
|
<p className="mt-2">Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) return <div>Error: {error}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col p-6">
|
||||||
|
<div className="min-w-min bg-white shadow rounded-lg p-6 mb-20">
|
||||||
|
<ApplicationsStatusDescription />
|
||||||
|
|
||||||
|
{role === "Validator" && (
|
||||||
|
<Search value={applicantName} setValue={handleSelect} />
|
||||||
|
)}
|
||||||
|
{renderTable()}
|
||||||
|
<Pagination
|
||||||
|
numOfItems={numOfApplications}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Applications;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const ApplicationTable = ({ title, applications,}) => {
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
{/* <h2 className="text-xl font-bold mb-2">{title}</h2> */}
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="border-b p-4 text-gray-700">Topic</th>
|
||||||
|
<th className="border-b p-4 text-gray-700">Name</th>
|
||||||
|
<th className="border-b p-4 text-gray-700">Submitted</th>
|
||||||
|
<th className="border-b p-4 text-gray-700">Branch</th>
|
||||||
|
<th className="border-b p-4 text-gray-700">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{applications.map((app, index) => (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
// onClick={() => onRowClick({ ...app, currentStatus: title.split(" ")[0] })}
|
||||||
|
onClick={() => {
|
||||||
|
const location = window.location.pathname;
|
||||||
|
const newPath = location.split('/').slice(0, -1).join('/');
|
||||||
|
navigate(`${newPath}/${title.split(" ")[0]?.toLowerCase()}/${app.applicationId}`);
|
||||||
|
}}
|
||||||
|
className="odd:bg-gray-50 even:bg-white hover:bg-gray-200 cursor-pointer"
|
||||||
|
style={{ height: '50px' }}
|
||||||
|
>
|
||||||
|
<td className="p-4">{app.formData.eventName}</td>
|
||||||
|
<td className="p-4">{app.applicantName}</td>
|
||||||
|
<td className="p-4">{formatDateToDDMMYYYY(app.createdAt)}</td>
|
||||||
|
<td className="p-4">{app.formData.applicantDepartment}</td>
|
||||||
|
<td className="p-4 text-green-500">{title.split(" ")[0]}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApplicationTable;
|
||||||
|
|
||||||
|
function formatDateToDDMMYYYY(dateString) {
|
||||||
|
// Convert the ISO string to a Date object
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
// Extract the day, month, and year
|
||||||
|
const day = String(date.getDate()).padStart(2, '0'); // Ensures two-digit format
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are 0-based, so add 1
|
||||||
|
const year = date.getFullYear();
|
||||||
|
|
||||||
|
// Format the date as dd/mm/yyyy
|
||||||
|
return `${day}/${month}/${year}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useNavigate, useParams, useRouteLoaderData } from "react-router-dom";
|
||||||
|
|
||||||
|
function ApplicationsStatusDescription() {
|
||||||
|
const { role } =
|
||||||
|
useRouteLoaderData("Applicant-Root")?.data ||
|
||||||
|
useRouteLoaderData("Validator-Root")?.data;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { status } = useParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-50 shadow-md rounded-lg p-6 mb-8 border border-slate-400">
|
||||||
|
<div className="flex justify-between items-center mb-6 gap-5">
|
||||||
|
<h1 className="text-3xl font-semibold text-gray-800">
|
||||||
|
{`${status.toUpperCase()} APPLICATIONS`}
|
||||||
|
</h1>
|
||||||
|
{role === "Applicant" && (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => navigate("../form")}
|
||||||
|
className="flex items-center bg-gradient-to-r from-red-600 to-red-800 hover:from-red-800 hover:to-red-600 text-white font-semibold py-2 px-4 rounded-lg shadow-lg transform transition duration-300 ease-in-out hover:scale-105"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-6 w-6 inline-block mr-2"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm">Create New Application</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 text-lg leading-relaxed sm:block hidden">
|
||||||
|
Easily track the details and statuses of all your submitted applications
|
||||||
|
in one place.
|
||||||
|
<br />
|
||||||
|
Stay updated and manage your applications with ease.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApplicationsStatusDescription;
|
||||||
59
frontend/src/pages/Applications/components/Search.jsx
Normal file
59
frontend/src/pages/Applications/components/Search.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import ReactSearchBox from "react-search-box";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
let applicantNamesCache = null;
|
||||||
|
|
||||||
|
const Search = ({ value, setValue }) => {
|
||||||
|
const [applicantNames, setApplicantNames] = useState([
|
||||||
|
{ key: "", value: "" },
|
||||||
|
]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function getApplicants() {
|
||||||
|
setIsLoading(true);
|
||||||
|
if (!applicantNamesCache) {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(
|
||||||
|
`${import.meta.env.VITE_APP_API_URL}/validator/getApplicantNames`,
|
||||||
|
{ withCredentials: true }
|
||||||
|
);
|
||||||
|
if (res.status === 200) {
|
||||||
|
applicantNamesCache = res.data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching applicant names:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setApplicantNames(applicantNamesCache);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
getApplicants();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) return <p>Loading search suggestions...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-7 p-2 rounded bg-gray-200">
|
||||||
|
<div className="flex flex-row items-start justify-start">
|
||||||
|
<div className="w-[90%]">
|
||||||
|
<ReactSearchBox
|
||||||
|
placeholder={`Applicant Name`}
|
||||||
|
data={applicantNames}
|
||||||
|
onSelect={(record) => setValue(record.item.value)}
|
||||||
|
clearOnSelect={false}
|
||||||
|
inputFontSize="large"
|
||||||
|
fuseConfigs={{ ignoreLocation: true, threshold: 0.3 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type='button' className="bg-red-700 hover:bg-red-800 text-white font-bold py-2 px-4 rounded ml-3" onClick={()=> setValue("")}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{value !== "" && <p className="text-gray-600 mt-3 ml-2 text-left">{`Showing Results for ${value}`}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Search;
|
||||||
124
frontend/src/pages/ContactUs/ContactUs.jsx
Normal file
124
frontend/src/pages/ContactUs/ContactUs.jsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
function ContactUs() {
|
||||||
|
const [formSubmitted, setFormSubmitted] = useState(false);
|
||||||
|
const [formError, setFormError] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Simulating form submission
|
||||||
|
setTimeout(() => {
|
||||||
|
setFormSubmitted(true);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full bg-white text-gray-900">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="relative bg-red-600 text-white h-[60vh] flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-red-700 opacity-60"></div>
|
||||||
|
<div className="relative z-10 text-center text-white pt-24">
|
||||||
|
<h1 className="text-3xl sm:text-4xl font-bold">Get in Touch with Us</h1>
|
||||||
|
<p className="mt-4 text-lg sm:text-xl">We’re eager to hear from prospective students, parents, and alumni.</p>
|
||||||
|
<a href="#contact-form" className="mt-6 inline-block py-2 px-6 bg-blue-700 text-white rounded-full hover:bg-white hover:text-blue-700 transition-all">Contact Us</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Contact Form */}
|
||||||
|
<section id="contact-form" className="py-12 px-4 sm:px-6 lg:px-8 bg-gray-100">
|
||||||
|
<div className="max-w-lg mx-auto">
|
||||||
|
<h2 className="text-2xl font-semibold text-center mb-6">Contact Form</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Name</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 mt-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-red-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 mt-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-red-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="subject" className="block text-sm font-medium text-gray-700">Subject</label>
|
||||||
|
<input
|
||||||
|
id="subject"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 mt-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-red-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="message" className="block text-sm font-medium text-gray-700">Message</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
rows="4"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 mt-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-red-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-2 px-4 bg-red-700 text-white rounded-md hover:bg-red-800 transition-all"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{formSubmitted && (
|
||||||
|
<p className="mt-4 text-center text-green-500">Thank you for reaching out! We'll get back to you soon.</p>
|
||||||
|
)}
|
||||||
|
{formError && (
|
||||||
|
<p className="mt-4 text-center text-red-500">There was an error submitting your form. Please try again.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Location Map */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-screen-xl mx-auto">
|
||||||
|
<h2 className="text-2xl font-semibold text-center mb-6">Our Location</h2>
|
||||||
|
<div className="relative">
|
||||||
|
<iframe
|
||||||
|
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3770.792765995658!2d72.89735127502728!3d19.072846982131118!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x3be7c627a20bcaa9%3A0xb2fd3bcfeac0052a!2sK.%20J.%20Somaiya%20College%20of%20Engineering!5e0!3m2!1sen!2sin!4v1729907307577!5m2!1sen!2sin"
|
||||||
|
width="100%" height="300" loading="lazy" title="KJSCE Location" style={{ border: "0", borderRadius: "10px" }}
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Contact Details */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 bg-gray-100">
|
||||||
|
<div className="max-w-screen-xl mx-auto flex flex-col sm:flex-row justify-between items-center">
|
||||||
|
<div className="flex flex-col items-center sm:items-start mb-6 sm:mb-0">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-800">Contact Details</h3>
|
||||||
|
<p className="mt-2 text-gray-600">
|
||||||
|
<span className="font-medium">Address:</span> K. J. Somaiya College of Engineering, Vidya Nagar, Mumbai, India
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-gray-600">
|
||||||
|
<span className="font-medium">Phone:</span> (022) 6728 8000
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-gray-600">
|
||||||
|
<span className="font-medium">Email:</span> info@somaiya.edu
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6 justify-center sm:justify-start">
|
||||||
|
<a href="https://facebook.com" className="hover:text-red-700 transform transition-transform duration-300 scale-110">Facebook</a>
|
||||||
|
<a href="https://twitter.com" className="hover:text-red-700 transform transition-transform duration-300 scale-110">Twitter</a>
|
||||||
|
<a href="https://linkedin.com" className="hover:text-red-700 transform transition-transform duration-300 scale-110">LinkedIn</a>
|
||||||
|
<a href="https://instagram.com" className="hover:text-red-700 transform transition-transform duration-300 scale-110">Instagram</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContactUs;
|
||||||
167
frontend/src/pages/Dashboard/Dashboard.jsx
Normal file
167
frontend/src/pages/Dashboard/Dashboard.jsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useNavigate, useRouteLoaderData } from "react-router-dom";
|
||||||
|
|
||||||
|
function Dashboard() {
|
||||||
|
const { role, user } =
|
||||||
|
useRouteLoaderData("Applicant-Root")?.data ||
|
||||||
|
useRouteLoaderData("Validator-Root")?.data;
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { userName, designation, department, institute } = user;
|
||||||
|
|
||||||
|
// Personalized greeting message (updated for professionalism and animation)
|
||||||
|
const greetingLine1 = `Hello, ${userName}!`;
|
||||||
|
const greetingLine2 = `${designation} in ${department} Department, ${institute}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="font-sans bg-white overflow-y-scroll scroll-smooth snap-y h-screen" >
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section
|
||||||
|
className="relative w-full h-screen flex items-center justify-center text-white overflow-hidden bg-cover bg-center snap-start"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url('https://source.unsplash.com/1600x900/?technology,research')`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-red-700 via-red-600 to-red-800 opacity-80"></div>
|
||||||
|
<div className="w-full text-center px-4 sm:px-6 md:px-8 relative z-10 animate-fade-in">
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold mb-4 tracking-wide drop-shadow-lg animate-slide-in-down break-words text-center">
|
||||||
|
{greetingLine1}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg sm:text-xl md:text-2xl font-medium text-gray-200 drop-shadow-md mb-6 animate-slide-in-left">
|
||||||
|
{greetingLine2}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-base sm:text-lg md:text-xl text-gray-300 drop-shadow-md mb-8 animate-slide-in-right">
|
||||||
|
{role === "Applicant"
|
||||||
|
? "Submit and track your funding applications with ease."
|
||||||
|
: "Review, validate, and manage applications efficiently."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{role === "Applicant" && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("../form")}
|
||||||
|
className="px-3 py-3 sm:px-5 sm:py-2 md:px-6 md:py-3 bg-white text-red-700 font-bold text-lg sm:text-base md:text-lg rounded-lg shadow-xl hover:bg-red-100 hover:scale-110 transition-all transform"
|
||||||
|
>
|
||||||
|
Start a New Application
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{role === "Validator" && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("../dashboard/pending")}
|
||||||
|
className="px-3 py-3 sm:px-5 sm:py-2 md:px-6 md:py-3 bg-white text-red-700 font-bold text-lg sm:text-base md:text-lg rounded-lg shadow-xl hover:bg-red-100 hover:scale-110 transition-all transform"
|
||||||
|
>
|
||||||
|
Check For New Applications
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-6 sm:bottom-10 left-1/2 transform -translate-x-1/2">
|
||||||
|
<a
|
||||||
|
href="#features"
|
||||||
|
className="text-white text-base sm:text-lg lg:text-xl transition-transform transform hover:scale-110"
|
||||||
|
>
|
||||||
|
↓ Scroll Down
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<section
|
||||||
|
id="features"
|
||||||
|
className="py-12 sm:py-16 px-6 sm:px-8 md:px-12 lg:px-16 bg-gradient-to-b from-white via-gray-100 to-red-50 min-h-screen snap-start"
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl sm:text-4xl md:text-5xl font-semibold text-center text-red-700 mb-10 sm:mb-12">
|
||||||
|
Our Key Features
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-12">
|
||||||
|
{/* Feature 1 */}
|
||||||
|
<div className="flex flex-col items-center bg-white p-6 sm:p-8 rounded-2xl shadow-lg hover:shadow-xl transition-transform transform hover:-translate-y-4 hover:scale-105">
|
||||||
|
<div className="text-5xl mb-4 text-red-700">🔍</div>
|
||||||
|
<h3 className="text-lg sm:text-xl md:text-2xl font-semibold mb-2 text-red-700 text-center">
|
||||||
|
View Your Applications
|
||||||
|
</h3>
|
||||||
|
<p className="text-center text-sm sm:text-base md:text-lg mb-6 text-gray-700">
|
||||||
|
{role === "Applicant"
|
||||||
|
? "Manage and track the status of your funding applications. Review feedback and make necessary updates."
|
||||||
|
: "Review and validate applications submitted by applicants. Approve or reject based on eligibility and guidelines."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
navigate("../dashboard/pending")
|
||||||
|
}
|
||||||
|
className="px-6 py-3 sm:px-5 sm:py-2 md:px-6 md:py-3 bg-red-700 text-white font-semibold text-lg sm:text-base md:text-lg rounded-lg hover:bg-red-600 transition-all transform hover:scale-110 shadow-md"
|
||||||
|
>
|
||||||
|
{role === "Applicant" ? "View Status" : "Review Application"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature 2 */}
|
||||||
|
{role === "Validator" && (
|
||||||
|
<div className="flex flex-col items-center bg-white p-6 sm:p-8 rounded-2xl shadow-lg hover:shadow-xl transition-transform transform hover:-translate-y-4 hover:scale-105">
|
||||||
|
<div className="text-5xl mb-4 text-red-700">📊</div>
|
||||||
|
<h3 className="text-lg sm:text-xl md:text-2xl font-semibold mb-2 text-red-700 text-center">
|
||||||
|
View Insights
|
||||||
|
</h3>
|
||||||
|
<p className="text-center text-sm sm:text-base md:text-lg mb-6 text-gray-700">
|
||||||
|
Analyze and gain insights about funding and related data.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("../report")}
|
||||||
|
className="px-6 py-3 sm:px-5 sm:py-2 md:px-6 md:py-3 bg-red-700 text-white font-semibold text-lg sm:text-base md:text-lg rounded-lg hover:bg-red-600 transition-all transform hover:scale-110 shadow-md"
|
||||||
|
>
|
||||||
|
View Insights
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feature 3 */}
|
||||||
|
{role === "Applicant" && (
|
||||||
|
<div className="flex flex-col items-center bg-white p-6 sm:p-8 rounded-2xl shadow-lg hover:shadow-xl transition-transform transform hover:-translate-y-4 hover:scale-105">
|
||||||
|
<div className="text-5xl mb-4 text-red-700">📝</div>
|
||||||
|
<h3 className="text-lg sm:text-xl md:text-2xl font-semibold mb-2 text-red-700 text-center">
|
||||||
|
Create New Application
|
||||||
|
</h3>
|
||||||
|
<p className="text-center text-sm sm:text-base md:text-lg mb-6 text-gray-700">
|
||||||
|
Start your application process to apply for funding and
|
||||||
|
financial assistance.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("../form")}
|
||||||
|
className="px-6 py-3 sm:px-5 sm:py-2 md:px-6 md:py-3 bg-red-700 text-white font-semibold text-lg sm:text-base md:text-lg rounded-lg hover:bg-red-600 transition-all transform hover:scale-110 shadow-md"
|
||||||
|
>
|
||||||
|
Start Application
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feature 4 */}
|
||||||
|
<div className="flex flex-col items-center bg-white p-6 sm:p-8 rounded-2xl shadow-lg hover:shadow-xl transition-transform transform hover:-translate-y-4 hover:scale-105">
|
||||||
|
<div className="text-5xl mb-4 text-red-700">📚</div>
|
||||||
|
<h3 className="text-lg sm:text-xl md:text-2xl font-semibold mb-2 text-red-700 text-center">
|
||||||
|
Understand the Policy
|
||||||
|
</h3>
|
||||||
|
<p className="text-center text-sm sm:text-base md:text-lg mb-6 text-gray-700">
|
||||||
|
Learn about the eligibility, funding process, and guidelines for
|
||||||
|
financial assistance.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("../policy")}
|
||||||
|
className="px-6 py-3 sm:px-5 sm:py-2 md:px-6 md:py-3 bg-red-700 text-white font-semibold text-lg sm:text-base md:text-lg rounded-lg hover:bg-red-600 transition-all transform hover:scale-110 shadow-md"
|
||||||
|
>
|
||||||
|
Learn More
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer Section */}
|
||||||
|
<footer className="bg-red-700 py-1 text-white text-center snap-center">
|
||||||
|
<p className="text-xs sm:text-sm md:text-base">
|
||||||
|
© {new Date().getFullYear()} Travel Policy SVU. All Rights Reserved.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
33
frontend/src/pages/Login/Login.css
Normal file
33
frontend/src/pages/Login/Login.css
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/* General styles for login page */
|
||||||
|
.login-page {
|
||||||
|
position: relative;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginPage_bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
object-fit:cover;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
margin: 2rem;
|
||||||
|
}
|
||||||
37
frontend/src/pages/Login/Login.jsx
Normal file
37
frontend/src/pages/Login/Login.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import './Login.css';
|
||||||
|
import loginPageBg from '/images/campus_bg.jpeg';
|
||||||
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
|
import ApplicantLogin from './components/ApplicantLogin';
|
||||||
|
import ValidatorLogin from './components/ValidatorLogin';
|
||||||
|
|
||||||
|
const Login = () => {
|
||||||
|
const [isApplicant, setIsApplicant] = useState(true);
|
||||||
|
|
||||||
|
const toggleRole = () => {
|
||||||
|
setIsApplicant(!isApplicant);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<img src={loginPageBg} className='loginPage_bg' />
|
||||||
|
<div className='login'>
|
||||||
|
<div className={`login-container`}>
|
||||||
|
{isApplicant ? (
|
||||||
|
<>
|
||||||
|
<ApplicantLogin changeRole={toggleRole} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ValidatorLogin changeRole={toggleRole} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
138
frontend/src/pages/Login/components/ApplicantLogin.jsx
Normal file
138
frontend/src/pages/Login/components/ApplicantLogin.jsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import './LoginAnimation.css';
|
||||||
|
|
||||||
|
function ApplicantLogin({ changeRole }) {
|
||||||
|
const [credentials, setCredentials] = useState({ email: 'faculty.computer.kjsce@example.com', password: 'securePassword123' });
|
||||||
|
const [animate, setAnimate] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false); // Loading state
|
||||||
|
|
||||||
|
const handleChangeRole = () => {
|
||||||
|
setAnimate(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
changeRole();
|
||||||
|
}, 800); // Match this timeout duration to your animation duration
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Basic Validation
|
||||||
|
if (!credentials.email || !credentials.password) {
|
||||||
|
setError('Please enter both email and password.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/\S+@\S+\.\S+/.test(credentials.email)) {
|
||||||
|
setError('Please enter a valid email address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true); // Show loading state
|
||||||
|
setError(''); // Reset previous errors
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${import.meta.env.VITE_APP_API_URL}/applicant-login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(credentials),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = '/applicant/dashboard';
|
||||||
|
} else {
|
||||||
|
setError(result.message || 'Invalid login credentials.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during login:', error);
|
||||||
|
setError('An error occurred. Please try again later.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false); // Hide loading state
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col md:flex-row bg-red-700 shadow-lg rounded-lg overflow-hidden max-w-4xl mx-auto">
|
||||||
|
<div className={`w-full md:w-3/4 bg-red-700 p-4 flex flex-col justify-center ${animate ? 'slide-out-right' : 'fade-in-fwd'}`}>
|
||||||
|
<h2 className="text-white text-xl md:text-2xl lg:text-3xl font-bold mb-3 hidden md:block">Travel Policy</h2>
|
||||||
|
<p className="text-white text-sm md:text-base mb-6 hidden md:block">
|
||||||
|
Our web application simplifies the process of requesting, approving, and managing financial support for research students and associates.
|
||||||
|
</p>
|
||||||
|
<h3 className="text-white text-lg md:text-xl font-bold">Validator?</h3>
|
||||||
|
<p className="text-white mb-3">Go to Validator’s Sign in</p>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className="bg-white text-red-700 text-sm md:text-base px-3 py-1.5 rounded-full font-semibold shadow-md hover:bg-gray-100 transition"
|
||||||
|
onClick={handleChangeRole}
|
||||||
|
>
|
||||||
|
Click Here
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`bg-white w-full md:w-3/4 p-8 flex flex-col justify-center ${animate ? 'text-blur-out' : 'fade-in-fwd'}`}>
|
||||||
|
<h2 className="text-lg md:text-xl lg:text-2xl font-bold mb-3">Login for Applicants<span role="img" aria-label="wave">👋</span></h2>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className="bg-gray-100 text-gray-700 text-sm md:text-base px-4 py-2 rounded-full font-semibold mb-3 shadow-md flex items-center justify-center hover:bg-gray-200 transition-transform transform hover:scale-105"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 mr-2"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path d="M29.074 13.3887H28V13.3333H16V18.6667H23.5354C22.436 21.7713 19.482 24 16 24C11.582 24 8.00002 20.418 8.00002 16C8.00002 11.582 11.582 8 16 8C18.0394 8 19.8947 8.76934 21.3074 10.026L25.0787 6.25467C22.6974 4.03534 19.512 2.66667 16 2.66667C8.63669 2.66667 2.66669 8.63667 2.66669 16C2.66669 23.3633 8.63669 29.3333 16 29.3333C23.3634 29.3333 29.3334 23.3633 29.3334 16C29.3334 15.106 29.2414 14.2333 29.074 13.3887Z" fill="#FFC107"/>
|
||||||
|
<path d="M4.20398 9.794L8.58465 13.0067C9.76998 10.072 12.6406 8 16 8C18.0393 8 19.8946 8.76934 21.3073 10.026L25.0786 6.25467C22.6973 4.03534 19.512 2.66667 16 2.66667C10.8786 2.66667 6.43731 5.558 4.20398 9.794Z" fill="#FF3D00"/>
|
||||||
|
<path d="M16 29.3333C19.444 29.3333 22.5733 28.0153 24.9393 25.872L20.8127 22.38C19.429 23.4323 17.7383 24.0014 16 24C12.532 24 9.58734 21.7887 8.478 18.7027L4.13 22.0527C6.33667 26.3707 10.818 29.3333 16 29.3333Z" fill="#4CAF50"/>
|
||||||
|
<path d="M29.074 13.3887H28V13.3333H16V18.6667H23.5353C23.0095 20.1443 22.0622 21.4354 20.8107 22.3807L20.8127 22.3793L24.9393 25.8713C24.6473 26.1367 29.3333 22.6667 29.3333 16C29.3333 15.106 29.2413 14.2333 29.074 13.3887Z" fill="#1976D2"/>
|
||||||
|
</svg>
|
||||||
|
Login With Google
|
||||||
|
</button>
|
||||||
|
<p className="text-center text-gray-500 text-xs md:text-sm mb-3">or use email</p>
|
||||||
|
|
||||||
|
{/* Display Error Message */}
|
||||||
|
{error && <div className="text-red-600 text-sm mb-3">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
placeholder="Email"
|
||||||
|
className="w-full mb-3 p-2 border border-gray-300 rounded-lg text-sm md:text-base focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||||
|
value={credentials.email}
|
||||||
|
onChange={(event) => setCredentials(prev => ({ ...prev, email: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
className="w-full mb-3 p-2 border border-gray-300 rounded-lg text-sm md:text-base focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||||
|
autoComplete='on'
|
||||||
|
value={credentials.password}
|
||||||
|
onChange={(event) => setCredentials(prev => ({ ...prev, password: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between mb-3">
|
||||||
|
<label className="flex items-center mb-2 md:mb-0 text-sm md:text-base">
|
||||||
|
<input type="checkbox" className="mr-2" />
|
||||||
|
<span>Remember me</span>
|
||||||
|
</label>
|
||||||
|
<a href="#" className="text-red-700 text-sm md:text-base hover:underline">Forgot Password?</a>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`bg-red-700 text-white text-sm md:text-base w-full py-2 rounded-lg font-semibold shadow-md hover:bg-red-800 transition ${loading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Logging in...' : 'Log in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApplicantLogin;
|
||||||
143
frontend/src/pages/Login/components/LoginAnimation.css
Normal file
143
frontend/src/pages/Login/components/LoginAnimation.css
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* ----------------------------------------
|
||||||
|
* animation slide-out-right
|
||||||
|
* ----------------------------------------
|
||||||
|
*/
|
||||||
|
@-webkit-keyframes slide-out-right {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: translateX(0);
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
-webkit-transform: translateX(1000px);
|
||||||
|
transform: translateX(1000px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-out-right {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: translateX(0);
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
-webkit-transform: translateX(1000px);
|
||||||
|
transform: translateX(1000px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply animation to the specific class */
|
||||||
|
.slide-out-right {
|
||||||
|
-webkit-animation: slide-out-right 1s cubic-bezier(0.550, 0.085, 0.680, 0.530) both;
|
||||||
|
animation: slide-out-right 1s cubic-bezier(0.550, 0.085, 0.680, 0.530) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ----------------------------------------
|
||||||
|
* animation slide-out-left
|
||||||
|
* ----------------------------------------
|
||||||
|
*/
|
||||||
|
@-webkit-keyframes slide-out-left {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: translateX(0);
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
-webkit-transform: translateX(-1000px);
|
||||||
|
transform: translateX(-1000px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes slide-out-left {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: translateX(0);
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
-webkit-transform: translateX(-1000px);
|
||||||
|
transform: translateX(-1000px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply animation to the specific class */
|
||||||
|
.slide-out-left {
|
||||||
|
-webkit-animation: slide-out-left 1s cubic-bezier(0.550, 0.085, 0.680, 0.530) both;
|
||||||
|
animation: slide-out-left 1s cubic-bezier(0.550, 0.085, 0.680, 0.530) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ----------------------------------------
|
||||||
|
* animation text-blur-out
|
||||||
|
* ----------------------------------------
|
||||||
|
*/
|
||||||
|
@-webkit-keyframes text-blur-out {
|
||||||
|
0% {
|
||||||
|
-webkit-filter: blur(0.01);
|
||||||
|
filter: blur(0.01);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
-webkit-filter: blur(12px) opacity(0%);
|
||||||
|
filter: blur(12px) opacity(0%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes text-blur-out {
|
||||||
|
0% {
|
||||||
|
-webkit-filter: blur(0.01);
|
||||||
|
filter: blur(0.01);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
-webkit-filter: blur(12px) opacity(0%);
|
||||||
|
filter: blur(12px) opacity(0%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply animation to the specific class */
|
||||||
|
.text-blur-out {
|
||||||
|
-webkit-animation: text-blur-out 0.7s cubic-bezier(0.550, 0.085, 0.680, 0.530) both;
|
||||||
|
animation: text-blur-out 0.7s cubic-bezier(0.550, 0.085, 0.680, 0.530) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ----------------------------------------
|
||||||
|
* animation fade-in-fwd
|
||||||
|
* ----------------------------------------
|
||||||
|
*/
|
||||||
|
@-webkit-keyframes fade-in-fwd {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: translateZ(-80px);
|
||||||
|
transform: translateZ(-80px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
-webkit-transform: translateZ(0);
|
||||||
|
transform: translateZ(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes fade-in-fwd {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: translateZ(-80px);
|
||||||
|
transform: translateZ(-80px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
-webkit-transform: translateZ(0);
|
||||||
|
transform: translateZ(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply animation to the specific class */
|
||||||
|
.fade-in-fwd {
|
||||||
|
-webkit-animation: fade-in-fwd 0.6s cubic-bezier(0.390, 0.575, 0.565, 1.000) both;
|
||||||
|
animation: fade-in-fwd 0.6s cubic-bezier(0.390, 0.575, 0.565, 1.000) both;
|
||||||
|
}
|
||||||
143
frontend/src/pages/Login/components/ValidatorLogin.jsx
Normal file
143
frontend/src/pages/Login/components/ValidatorLogin.jsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import './LoginAnimation.css';
|
||||||
|
|
||||||
|
function ValidatorLogin({ changeRole }) {
|
||||||
|
const [credentials, setCredentials] = useState({ email: 'hod.computer.kjsce@example.com', password: 'securePassword123' });
|
||||||
|
const [animate, setAnimate] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false); // Loading state
|
||||||
|
|
||||||
|
const handleChangeRole = () => {
|
||||||
|
setAnimate(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
changeRole();
|
||||||
|
}, 800); // Ensure this matches your CSS animation duration
|
||||||
|
};
|
||||||
|
|
||||||
|
// Basic email validation
|
||||||
|
const validateEmail = (email) => {
|
||||||
|
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (!credentials.email || !credentials.password) {
|
||||||
|
setError('Please enter both email and password.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
if (!validateEmail(credentials.email)) {
|
||||||
|
setError('Please enter a valid email address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true); // Show loading state
|
||||||
|
setError(''); // Reset previous errors
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${import.meta.env.VITE_APP_API_URL}/validator-login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(credentials),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
// Handle successful login (navigate, store tokens, etc.)
|
||||||
|
window.location.href = '/validator/dashboard';
|
||||||
|
} else {
|
||||||
|
setError(result.message || 'Invalid login credentials.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during login:', error);
|
||||||
|
setError('An error occurred. Please try again later.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false); // Hide loading state
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col md:flex-row bg-red-700 shadow-lg rounded-lg overflow-hidden max-w-4xl mx-auto">
|
||||||
|
<div className={`bg-white w-full md:w-3/4 p-8 flex flex-col justify-center ${animate ? 'text-blur-out' : 'fade-in-fwd'}`}>
|
||||||
|
<h2 className="text-lg md:text-xl lg:text-2xl font-bold mb-3">Login for Validator<span role="img" aria-label="wave">👋</span></h2>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className="bg-gray-100 text-gray-700 text-sm md:text-base px-4 py-2 rounded-full font-semibold mb-3 shadow-md flex items-center justify-center hover:bg-gray-200 transition-transform transform hover:scale-105"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 mr-2" // Adjust the size of the icon if needed
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path d="M29.074 13.3887H28V13.3333H16V18.6667H23.5354C22.436 21.7713 19.482 24 16 24C11.582 24 8.00002 20.418 8.00002 16C8.00002 11.582 11.582 8 16 8C18.0394 8 19.8947 8.76934 21.3074 10.026L25.0787 6.25467C22.6974 4.03534 19.512 2.66667 16 2.66667C8.63669 2.66667 2.66669 8.63667 2.66669 16C2.66669 23.3633 8.63669 29.3333 16 29.3333C23.3634 29.3333 29.3334 23.3633 29.3334 16C29.3334 15.106 29.2414 14.2333 29.074 13.3887Z" fill="#FFC107"/>
|
||||||
|
<path d="M4.20398 9.794L8.58465 13.0067C9.76998 10.072 12.6406 8 16 8C18.0393 8 19.8946 8.76934 21.3073 10.026L25.0786 6.25467C22.6973 4.03534 19.512 2.66667 16 2.66667C10.8786 2.66667 6.43731 5.558 4.20398 9.794Z" fill="#FF3D00"/>
|
||||||
|
<path d="M16 29.3333C19.444 29.3333 22.5733 28.0153 24.9393 25.872L20.8127 22.38C19.429 23.4323 17.7383 24.0014 16 24C12.532 24 9.58734 21.7887 8.478 18.7027L4.13 22.0527C6.33667 26.3707 10.818 29.3333 16 29.3333Z" fill="#4CAF50"/>
|
||||||
|
<path d="M29.074 13.3887H28V13.3333H16V18.6667H23.5353C23.0095 20.1443 22.0622 21.4354 20.8107 22.3807L20.8127 22.3793L24.9393 25.8713C24.6473 26.1367 29.3333 22.6667 29.3333 16C29.3333 15.106 29.2413 14.2333 29.074 13.3887Z" fill="#1976D2"/>
|
||||||
|
</svg>
|
||||||
|
Login With Google
|
||||||
|
</button>
|
||||||
|
<p className="text-center text-gray-500 text-xs md:text-sm mb-3">or use email</p>
|
||||||
|
|
||||||
|
{/* Display Error Message */}
|
||||||
|
{error && <div className="text-red-600 text-sm mb-3">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
placeholder="Email"
|
||||||
|
className="w-full mb-3 p-2 border border-gray-300 rounded-lg text-sm md:text-base focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||||
|
value={credentials.email}
|
||||||
|
onChange={(event) => setCredentials(prev => ({ ...prev, email: event.target.value }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
className="w-full mb-3 p-2 border border-gray-300 rounded-lg text-sm md:text-base focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||||
|
value={credentials.password}
|
||||||
|
onChange={(event) => setCredentials(prev => ({ ...prev, password: event.target.value }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between mb-3">
|
||||||
|
<label className="flex items-center mb-2 md:mb-0 text-sm md:text-base">
|
||||||
|
<input type="checkbox" className="mr-2" />
|
||||||
|
<span>Remember me</span>
|
||||||
|
</label>
|
||||||
|
<a href="#" className="text-red-700 text-sm md:text-base hover:underline">Forgot Password?</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`bg-red-700 text-white text-sm md:text-base w-full py-2 rounded-lg font-semibold shadow-md hover:bg-red-800 transition ${loading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Logging in...' : 'Log in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`w-full md:w-3/4 bg-red-700 p-4 flex flex-col justify-center ${animate ? 'slide-out-left' : 'fade-in-fwd'}`}>
|
||||||
|
<h2 className="text-white text-xl md:text-2xl lg:text-3xl font-bold mb-3 hidden md:block">Travel Policy</h2>
|
||||||
|
<p className="text-white text-sm md:text-base mb-6 hidden md:block">
|
||||||
|
Our web application simplifies the process of requesting, approving, and managing financial support for research students and associates.
|
||||||
|
</p>
|
||||||
|
<h3 className="text-white text-lg md:text-xl font-bold">Applicant?</h3>
|
||||||
|
<p className="text-white mb-3">Go to Applicant’s Sign in</p>
|
||||||
|
<button type='button' className="bg-white text-red-700 text-sm md:text-base px-3 py-1.5 rounded-full font-semibold shadow-md hover:bg-gray-100 transition" onClick={handleChangeRole}>Click Here</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ValidatorLogin;
|
||||||
0
frontend/src/pages/Policy/Policy.css
Normal file
0
frontend/src/pages/Policy/Policy.css
Normal file
11
frontend/src/pages/Policy/Policy.jsx
Normal file
11
frontend/src/pages/Policy/Policy.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
function Policy() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
Policy
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Policy
|
||||||
30
frontend/src/pages/Report/Data.js
Normal file
30
frontend/src/pages/Report/Data.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export const CardsData = [
|
||||||
|
{
|
||||||
|
title: "Total Funds Deployed",
|
||||||
|
color: {
|
||||||
|
backGround: "linear-gradient(#93A5CF, #E4EfE9)",
|
||||||
|
boxShadow: "0px 10px 20px 0px rgba(0, 0, 0, 0.1)",
|
||||||
|
},
|
||||||
|
value: "12,23,234",
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "Funds",
|
||||||
|
data: [20134, 1200, 23532, 23543, 12564,23346,23454,54634,45364,45634,45745,564],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Enrollment rate",
|
||||||
|
color: {
|
||||||
|
backGround: "linear-gradient(#93A5CF, #E4EfE9)",
|
||||||
|
boxShadow: "0px 10px 20px 0px rgba(0, 0, 0, 0.1)",
|
||||||
|
},
|
||||||
|
value: "90%",
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "Enrollment Trends",
|
||||||
|
data: [200, 145, 232, 543, 564,342,635,345,346,123,543],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
22
frontend/src/pages/Report/Report.jsx
Normal file
22
frontend/src/pages/Report/Report.jsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import Loading from "../../components/Loading";
|
||||||
|
import Charts from "../Report/components/charts";
|
||||||
|
import FilterDataForm from "./components/FilterDataForm";
|
||||||
|
|
||||||
|
function Report() {
|
||||||
|
const [reportData, setReportData] = useState({
|
||||||
|
data: [],
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col p-6">
|
||||||
|
<div className="bg-white shadow rounded-lg p-6 w-full">
|
||||||
|
<FilterDataForm setReportData={setReportData} setLoading={setLoading} />
|
||||||
|
{loading ? <Loading /> : <Charts reportData={reportData} />}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Report;
|
||||||
152
frontend/src/pages/Report/components/FilterDataForm.jsx
Normal file
152
frontend/src/pages/Report/components/FilterDataForm.jsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { Formik } from "formik";
|
||||||
|
import { useSubmit, useRouteLoaderData, useNavigation } from "react-router-dom";
|
||||||
|
import { filterDataFormFeilds } from "./FilterDataFormFeilds";
|
||||||
|
import * as yup from "yup";
|
||||||
|
import Input from "../../ApplicationForm/Input";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
function FilterDataForm({ setReportData, setLoading }) {
|
||||||
|
const { role, user } = useRouteLoaderData("Validator-Root")?.data;
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const isSubmittingNav = navigation.state === "submitting";
|
||||||
|
|
||||||
|
const prefilledData =
|
||||||
|
user?.institute || user?.department
|
||||||
|
? {
|
||||||
|
institute: user?.institute,
|
||||||
|
department: user?.department,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const formFields = prefilledData
|
||||||
|
? filterDataFormFeilds.map((section) => ({
|
||||||
|
...section,
|
||||||
|
fields: section.fields.map((field) => ({
|
||||||
|
...field,
|
||||||
|
disabled: prefilledData[field.name],
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
: filterDataFormFeilds;
|
||||||
|
|
||||||
|
const createInitialValuesScheme = (formFields) => {
|
||||||
|
const schema = {};
|
||||||
|
|
||||||
|
formFields?.forEach((section) => {
|
||||||
|
section?.fields?.forEach((field) => {
|
||||||
|
if (prefilledData) {
|
||||||
|
if (field.type === "miniForm" || field.type === "checkbox") {
|
||||||
|
schema[field.name] = JSON.parse(prefilledData[field.name]);
|
||||||
|
} else {
|
||||||
|
schema[field.name] = prefilledData[field.name];
|
||||||
|
}
|
||||||
|
} else if (field.type === "checkbox") {
|
||||||
|
schema[field.name] = false;
|
||||||
|
} else if (field.type === "miniForm") {
|
||||||
|
schema[field.name] = [];
|
||||||
|
} else {
|
||||||
|
schema[field.name] = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return schema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialValuesSchema = createInitialValuesScheme(formFields);
|
||||||
|
|
||||||
|
const createValidationSchema = (formFields) => {
|
||||||
|
const schema = {};
|
||||||
|
|
||||||
|
formFields?.forEach((section) => {
|
||||||
|
section.fields?.forEach((field) => {
|
||||||
|
if (field.validation) {
|
||||||
|
schema[field.name] = field.validation;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return yup.object().shape(schema);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationSchema = createValidationSchema(formFields);
|
||||||
|
|
||||||
|
const handleSubmit = async (values, { setSubmitting, setErrors }) => {
|
||||||
|
const { institute, department, year, applicationType } = values;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (institute) queryParams.append("institute", institute);
|
||||||
|
if (department) queryParams.append("department", department);
|
||||||
|
if (year) queryParams.append("year", year);
|
||||||
|
if (applicationType) queryParams.append("applicationType", applicationType);
|
||||||
|
|
||||||
|
const res = await axios.get(
|
||||||
|
`http://localhost:3000/validator/getReportData?${queryParams.toString()}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
withCredentials: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setReportData({data: res.data, query: values});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
setErrors({ submit: error.response.data.message });
|
||||||
|
} else {
|
||||||
|
setErrors({ submit: "An unexpected error occurred" });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Trigger form submission on first render
|
||||||
|
handleSubmit(initialValuesSchema, { setSubmitting: () => {}, setErrors: () => {} });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValuesSchema}
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
{({
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
handleChange,
|
||||||
|
handleBlur,
|
||||||
|
handleSubmit,
|
||||||
|
setFieldValue, // Use setFieldValue for file handling
|
||||||
|
isSubmitting,
|
||||||
|
}) => (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-transparent">
|
||||||
|
<Input
|
||||||
|
values={values}
|
||||||
|
errors={errors}
|
||||||
|
touched={touched}
|
||||||
|
handleChange={handleChange}
|
||||||
|
handleBlur={handleBlur}
|
||||||
|
setFieldValue={setFieldValue} // Pass setFieldValue for file handling
|
||||||
|
formFeilds={formFields}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || isSubmittingNav}
|
||||||
|
className="w-full flex items-center justify-center bg-gradient-to-r from-red-600 to-red-800 hover:from-red-800 hover:to-red-600 text-white font-semibold py-2 px-4 rounded-lg shadow-lg transform transition duration-300 ease-in-out disabled:bg-gray-400"
|
||||||
|
>
|
||||||
|
{isSubmitting || isSubmittingNav ? "Gettting Data" : "Get Data"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilterDataForm;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import * as yup from "yup";
|
||||||
|
import {
|
||||||
|
institutes,
|
||||||
|
instituteDepartmentMapping,
|
||||||
|
} from "../../../components/BaseData";
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const yearOptions = [];
|
||||||
|
for (let year = 2018; year <= currentYear; year++) {
|
||||||
|
yearOptions.push({ label: year.toString(), value: year.toString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterDataFormFeilds = [
|
||||||
|
{
|
||||||
|
label: "Travel Polciy Report",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: "Select Institute",
|
||||||
|
name: "institute",
|
||||||
|
type: "dropdown",
|
||||||
|
options: {
|
||||||
|
"": institutes,
|
||||||
|
},
|
||||||
|
validation: yup
|
||||||
|
.string()
|
||||||
|
.notRequired("Department selection is notRequired"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
depend: "institute",
|
||||||
|
label: "Select Department",
|
||||||
|
name: "department",
|
||||||
|
type: "dropdown",
|
||||||
|
options: instituteDepartmentMapping,
|
||||||
|
validation: yup
|
||||||
|
.string()
|
||||||
|
.notRequired("Department selection is notRequired"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Select Application Type",
|
||||||
|
name: "applicationType",
|
||||||
|
type: "dropdown",
|
||||||
|
options: {
|
||||||
|
"": [
|
||||||
|
{ label: "Student Applications", value: "STUDENT" },
|
||||||
|
{ label: "Faculty Applications", value: "FACULTY" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
validation: yup
|
||||||
|
.string()
|
||||||
|
.notRequired("Department selection is notRequired"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Select Year",
|
||||||
|
name: "year",
|
||||||
|
type: "dropdown",
|
||||||
|
options: {
|
||||||
|
"": yearOptions,
|
||||||
|
},
|
||||||
|
validation: yup.string().notRequired("Year is required"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export { filterDataFormFeilds };
|
||||||
90
frontend/src/pages/Report/components/OverTheYearsLine.jsx
Normal file
90
frontend/src/pages/Report/components/OverTheYearsLine.jsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { Line } from "react-chartjs-2";
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from "chart.js";
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
);
|
||||||
|
|
||||||
|
function OverTheYearsLine() {
|
||||||
|
const options = {
|
||||||
|
responsive: true, // Make the chart responsive to window resizing
|
||||||
|
maintainAspectRatio: false, // Allow chart size to change dynamically (optional)
|
||||||
|
|
||||||
|
// Title configuration
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Applications Over the Years", // Set a title for the chart
|
||||||
|
font: {
|
||||||
|
size: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
// Customize tooltips
|
||||||
|
callbacks: {
|
||||||
|
label: function (context) {
|
||||||
|
// Format tooltip labels
|
||||||
|
return `${context.dataset.label}: ${context.raw} steps`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: "top", // Legend position: 'top', 'left', 'bottom', 'right'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Scales configuration (e.g., setting up x and y axes)
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
// X-axis configuration (labels are auto-set)
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Year", // Label for the X-axis
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
// Y-axis configuration
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Number of Applications", // Label for the Y-axis
|
||||||
|
},
|
||||||
|
// ticks: {
|
||||||
|
// // Custom tick marks
|
||||||
|
// beginAtZero: true, // Start Y-axis from 0
|
||||||
|
// stepSize: 9000, // Tick step size for Y-axis
|
||||||
|
// },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: [2020, 2021, 2022, 2023, 2024],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Steps",
|
||||||
|
data: [30, 50, 45, 90, 35],
|
||||||
|
borderColor: "rgb(75, 192, 192)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Line options={options} data={data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OverTheYearsLine;
|
||||||
65
frontend/src/pages/Report/components/OverTheYearsPie.jsx
Normal file
65
frontend/src/pages/Report/components/OverTheYearsPie.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Pie } from "react-chartjs-2";
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
ArcElement,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from "chart.js";
|
||||||
|
|
||||||
|
// Register chart components
|
||||||
|
ChartJS.register(ArcElement, Tooltip, Legend);
|
||||||
|
|
||||||
|
function OverTheYearsPie() {
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Steps Distribution Over Years",
|
||||||
|
font: {
|
||||||
|
size: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function (context) {
|
||||||
|
return `${context.label}: ${context.raw} steps`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: "top",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: ["2020", "2021", "2022", "2023", "2024"], // Labels for the pie slices
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: [3000, 5000, 4500, 9000, 12000], // Data for the pie chart
|
||||||
|
backgroundColor: [
|
||||||
|
"rgba(75, 192, 192, 0.5)", // Color for the 2020 slice
|
||||||
|
"rgba(255, 99, 132, 0.5)", // Color for the 2021 slice
|
||||||
|
"rgba(54, 162, 235, 0.5)", // Color for the 2022 slice
|
||||||
|
"rgba(153, 102, 255, 0.5)", // Color for the 2023 slice
|
||||||
|
"rgba(255, 159, 64, 0.5)", // Color for the 2024 slice
|
||||||
|
],
|
||||||
|
borderColor: [
|
||||||
|
"rgb(75, 192, 192)",
|
||||||
|
"rgb(255, 99, 132)",
|
||||||
|
"rgb(54, 162, 235)",
|
||||||
|
"rgb(153, 102, 255)",
|
||||||
|
"rgb(255, 159, 64)",
|
||||||
|
],
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Pie options={options} data={data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OverTheYearsPie;
|
||||||
166
frontend/src/pages/Report/components/ReportPDF.jsx
Normal file
166
frontend/src/pages/Report/components/ReportPDF.jsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
Document,
|
||||||
|
StyleSheet,
|
||||||
|
Image,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
|
||||||
|
// Create styles
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "white",
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
margin: 10,
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
viewer: {
|
||||||
|
width: "75vw", // Full width
|
||||||
|
height: "100vh", // Full height
|
||||||
|
},
|
||||||
|
cardContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginVertical: 10,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: "45%",
|
||||||
|
padding: 10,
|
||||||
|
backgroundColor: "#f8e7d1",
|
||||||
|
borderRadius: 5,
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
chartContainer: {
|
||||||
|
marginVertical: 20,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
display: "table",
|
||||||
|
width: "auto",
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#bfbfbf",
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
borderRightWidth: 0,
|
||||||
|
},
|
||||||
|
tableRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
},
|
||||||
|
tableColHeader: {
|
||||||
|
width: "25%",
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: "#bfbfbf",
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
backgroundColor: "#f2f2f2",
|
||||||
|
padding: 5,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
tableCol: {
|
||||||
|
width: "25%",
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: "#bfbfbf",
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
padding: 5,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
tableCellHeader: {
|
||||||
|
margin: 5,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
tableCell: {
|
||||||
|
margin: 5,
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: 400,
|
||||||
|
height: 300,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Document Component
|
||||||
|
const ReportPDF = ({ tableData, chartImages }) => {
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page size="A4" style={styles.page}>
|
||||||
|
{/* Title */}
|
||||||
|
<Text style={styles.sectionTitle}>Travel Policy Report</Text>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{/* <View style={styles.cardContainer}>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text>Total Funds Deployed</Text>
|
||||||
|
<Text>12,23,234</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text>Enrollment Rate</Text>
|
||||||
|
<Text>90%</Text>
|
||||||
|
</View>
|
||||||
|
</View> */}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<View style={styles.table}>
|
||||||
|
<View style={styles.tableRow}>
|
||||||
|
<View style={styles.tableColHeader}>
|
||||||
|
<Text style={styles.tableCellHeader}>ID</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tableColHeader}>
|
||||||
|
<Text style={styles.tableCellHeader}>Stream</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tableColHeader}>
|
||||||
|
<Text style={styles.tableCellHeader}>Scholarship</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tableColHeader}>
|
||||||
|
<Text style={styles.tableCellHeader}>Funds</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{tableData?.map((row) => (
|
||||||
|
<View key={row.id} style={styles.tableRow}>
|
||||||
|
<View style={styles.tableCol}>
|
||||||
|
<Text style={styles.tableCell}>{row.id}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tableCol}>
|
||||||
|
<Text style={styles.tableCell}>{row.Stream}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tableCol}>
|
||||||
|
<Text style={styles.tableCell}>{row.Scholarship}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tableCol}>
|
||||||
|
<Text style={styles.tableCell}>{row.Funds}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
{chartImages?.barChart && (
|
||||||
|
<Image src={chartImages.barChart} style={styles.image} />
|
||||||
|
)}
|
||||||
|
{chartImages?.pieChart1 && (
|
||||||
|
<Image src={chartImages.pieChart1} style={styles.image} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chartImages?.pieChart2 && (
|
||||||
|
<Image src={chartImages.pieChart2} style={styles.image} />
|
||||||
|
)}
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportPDF;
|
||||||
37
frontend/src/pages/Report/components/Table.jsx
Normal file
37
frontend/src/pages/Report/components/Table.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const Table = ({ tableData }) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ backgroundColor: "#f4f4f4" }}>
|
||||||
|
<th style={{ padding: "10px", border: "1px solid #ddd" }}>ID</th>
|
||||||
|
<th style={{ padding: "10px", border: "1px solid #ddd" }}>Stream</th>
|
||||||
|
<th style={{ padding: "10px", border: "1px solid #ddd" }}>Scholarship</th>
|
||||||
|
<th style={{ padding: "10px", border: "1px solid #ddd" }}>Funds</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tableData?.map((row) => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<td style={{ padding: "10px", border: "1px solid #ddd" }}>{row.id}</td>
|
||||||
|
<td style={{ padding: "10px", border: "1px solid #ddd" }}>{row.Stream}</td>
|
||||||
|
<td style={{ padding: "10px", border: "1px solid #ddd" }}>{row.Scholarship}</td>
|
||||||
|
<td style={{ padding: "10px", border: "1px solid #ddd" }}>{row.Funds}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Table;
|
||||||
146
frontend/src/pages/Report/components/approved.jsx
Normal file
146
frontend/src/pages/Report/components/approved.jsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Bar } from "react-chartjs-2";
|
||||||
|
|
||||||
|
const ChartWithDropdown = () => {
|
||||||
|
// Chart data options for faculty, students, HOI, and HOD
|
||||||
|
const chartDataOptions = {
|
||||||
|
faculty: {
|
||||||
|
approved: {
|
||||||
|
labels: ["Jan", "Feb", "Mar", "April", "May","June","July","Aug","Sep","Nov","Dec"],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Approved Applications (Faculty)",
|
||||||
|
data: [100, 150, 200, 250, 300,400,900,132,920,1000,890 ,100],
|
||||||
|
backgroundColor: "rgba(75, 192, 192, 0.5)",
|
||||||
|
borderColor: "rgb(75, 192, 192)",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rejected: {
|
||||||
|
labels: ["Jan", "Feb", "Mar", "April", "May","June","July","Aug","Sep","Nov","Dec"],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Rejected Applications (Faculty)",
|
||||||
|
data: [50, 60, 70, 80, 20,40,90,78,23,29,98,33],
|
||||||
|
backgroundColor: "rgba(255, 99, 132, 0.5)",
|
||||||
|
borderColor: "rgb(255, 99, 132)",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
HOI: {
|
||||||
|
approved: {
|
||||||
|
labels: ["Jan", "Feb", "Mar", "April", "May","June","July","Aug","Sep","Nov","Dec"],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Approved Applications (HOI)",
|
||||||
|
data: [1200, 1500, 1800, 2200, 2500,2000,1999,3453,2345,5633,2345,5647],
|
||||||
|
backgroundColor: "rgba(54, 162, 235, 0.5)",
|
||||||
|
borderColor: "rgb(54, 162, 235)",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rejected: {
|
||||||
|
labels: ["Jan", "Feb", "Mar", "April", "May","June","July","Aug","Sep","Nov","Dec"],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Rejected Applications (HOI)",
|
||||||
|
data: [200, 300, 400, 500, 450, 350, 320, 410, 360, 430, 300, 250],
|
||||||
|
backgroundColor: "rgba(255, 159, 64, 0.5)",
|
||||||
|
borderColor: "rgb(255, 159, 64)",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
HOD: {
|
||||||
|
approved: {
|
||||||
|
labels: ["Jan", "Feb", "Mar", "April", "May","June","July","Aug","Sep","Nov","Dec"],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Approved Applications (HOD)",
|
||||||
|
data: [300, 400, 500, 450, 400, 350, 300, 250, 200, 150, 100, 50],
|
||||||
|
backgroundColor: "rgba(153, 102, 255, 0.5)",
|
||||||
|
borderColor: "rgb(153, 102, 255)",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rejected: {
|
||||||
|
labels: ["Jan", "Feb", "Mar", "April", "May","June","July","Aug","Sep","Nov","Dec"],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Rejected Applications (HOD)",
|
||||||
|
data: [30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140],
|
||||||
|
backgroundColor: "rgba(255, 206, 86, 0.5)",
|
||||||
|
borderColor: "rgb(255, 206, 86)",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true },
|
||||||
|
title: { display: true, text: "Applications Over the Years" },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { title: { display: true, text: "Year" } },
|
||||||
|
y: { title: { display: true, text: "Number of Applications" }, beginAtZero: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const [category, setCategory] = useState("faculty"); // Faculty, HOI, or HOD
|
||||||
|
const [applicationType, setApplicationType] = useState("approved"); // Approved or Rejected
|
||||||
|
|
||||||
|
// Fetch the data based on the selected category and application type
|
||||||
|
const data =
|
||||||
|
chartDataOptions[category]?.[applicationType] ||
|
||||||
|
chartDataOptions["faculty"]["approved"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: "100%", margin: "auto", padding: "20px", flexGrow: 1 }}>
|
||||||
|
{/* Dropdown for selecting category */}
|
||||||
|
<div style={{ marginBottom: "20px" }}>
|
||||||
|
<label htmlFor="category-select" style={{ marginRight: "10px" }}>
|
||||||
|
Select Category:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="category-select"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
style={{ padding: "5px", fontSize: "16px", marginRight: "20px", borderRadius: "15px", textAlign: "center", border: "2px solid black" }}
|
||||||
|
>
|
||||||
|
<option value="faculty">Faculty</option>
|
||||||
|
<option value="HOI">HOI</option>
|
||||||
|
<option value="HOD">HOD</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Dropdown for selecting application type */}
|
||||||
|
<label htmlFor="type-select" style={{ marginRight: "10px" }}>
|
||||||
|
Select Application Type:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="type-select"
|
||||||
|
value={applicationType}
|
||||||
|
onChange={(e) => setApplicationType(e.target.value)}
|
||||||
|
style={{ padding: "5px", fontSize: "16px", borderRadius: "15px", textAlign: "center", border: "2px solid black" }}
|
||||||
|
>
|
||||||
|
<option value="approved">Approved Applications</option>
|
||||||
|
<option value="rejected">Rejected Applications</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
{data && <Bar data={data} options={chartOptions} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChartWithDropdown;
|
||||||
143
frontend/src/pages/Report/components/card.jsx
Normal file
143
frontend/src/pages/Report/components/card.jsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import "./cards.css";
|
||||||
|
import { Bar } from "react-chartjs-2";
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from "chart.js";
|
||||||
|
|
||||||
|
// Register chart.js components
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
);
|
||||||
|
|
||||||
|
const Card = (props) => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
onClick={() => setExpanded(!expanded)} // Toggle the state
|
||||||
|
className="motion_card"
|
||||||
|
>
|
||||||
|
{!expanded && ( // Render CompactCard when NOT expanded
|
||||||
|
<motion.div>
|
||||||
|
<CompactCard param={props} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && ( // Render ExpandedCard when expanded
|
||||||
|
<motion.div>
|
||||||
|
<ExpandedCard param={props} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compact Card Component
|
||||||
|
function CompactCard({ param }) {
|
||||||
|
return (
|
||||||
|
<div className="CompactCard">
|
||||||
|
<div className="data">
|
||||||
|
<h1>{param.title}</h1>
|
||||||
|
</div>
|
||||||
|
<span>{param.value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded Card Component
|
||||||
|
function ExpandedCard({ param }) {
|
||||||
|
const barOptions = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Number of Applications Over the Years ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Year",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Number of Applications",
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels: ["Jan", "Feb", "Mar", "April", "May","June","July","Aug","Sep","Nov","Dec"], // Example years
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: param.series[0].name, // e.g., "Applications"
|
||||||
|
data: param.series[0].data, // e.g., [100, 150, 200, 250, 300]
|
||||||
|
backgroundColor: "rgba(75, 192, 192, 0.6)", // Bar color
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
|
className="ExpandedCard"
|
||||||
|
style={{
|
||||||
|
position: "fixed", // Make it fixed to cover the whole screen
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)", // Semi-transparent background
|
||||||
|
zIndex: 999, // Ensure it's on top of other elements
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="expandedCardContent"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
padding: "20px",
|
||||||
|
borderRadius: "10px",
|
||||||
|
width: "70%",
|
||||||
|
height: "70%"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="data">
|
||||||
|
<h1>{param.title}</h1>
|
||||||
|
</div>
|
||||||
|
<Bar options={barOptions} data={chartData} />
|
||||||
|
<span>{param.value}</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Card;
|
||||||
63
frontend/src/pages/Report/components/cards.css
Normal file
63
frontend/src/pages/Report/components/cards.css
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
.generalInfo{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
width: 30%;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards{
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
}
|
||||||
|
.Cards{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
.CompactCard{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 20px;
|
||||||
|
width: 300px;
|
||||||
|
height: 200px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: antiquewhite;
|
||||||
|
border-width: 5px;
|
||||||
|
border-color: rgb(85, 85, 85);
|
||||||
|
border-radius: 5px;
|
||||||
|
filter: drop-shadow(2px 4px 6px rgb(114, 114, 114));
|
||||||
|
}
|
||||||
|
.CompactCard:hover
|
||||||
|
{
|
||||||
|
filter: drop-shadow(2px 4px 6px rgb(255, 255, 255));
|
||||||
|
}
|
||||||
|
.data>h1
|
||||||
|
{
|
||||||
|
font-size: large;
|
||||||
|
font-weight: 1000;
|
||||||
|
}
|
||||||
|
.CompactCard>span
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.motionCard{
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.h{
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
margin: 10px;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
}
|
||||||
28
frontend/src/pages/Report/components/cards.jsx
Normal file
28
frontend/src/pages/Report/components/cards.jsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import './cards.css'
|
||||||
|
import React from 'react'
|
||||||
|
import Card from './card'
|
||||||
|
import { CardsData } from '../Data';
|
||||||
|
|
||||||
|
const Cards= () =>{
|
||||||
|
return(
|
||||||
|
<div className="Cards">
|
||||||
|
{CardsData.map((card , id)=>{
|
||||||
|
return(
|
||||||
|
<div className="parentContainer">
|
||||||
|
<Card
|
||||||
|
title={card.title}
|
||||||
|
color={card.color}
|
||||||
|
value={card.value}
|
||||||
|
series={card.series}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
})}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default Cards
|
||||||
440
frontend/src/pages/Report/components/charts.jsx
Normal file
440
frontend/src/pages/Report/components/charts.jsx
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import ChartWithDropdown from "./approved";
|
||||||
|
import Cards from "./cards";
|
||||||
|
import "./cards.css";
|
||||||
|
import { Bar, Pie } from "react-chartjs-2";
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
ArcElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from "chart.js";
|
||||||
|
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||||
|
import Table from "./Table";
|
||||||
|
import { PDFDownloadLink, PDFViewer } from "@react-pdf/renderer";
|
||||||
|
import ApprovalVsRejectionTrends from "./map";
|
||||||
|
import ReportPDF from "./reportPDF";
|
||||||
|
|
||||||
|
// Register chart components for all three types (Line, Bar, Pie)
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
ArcElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ChartDataLabels
|
||||||
|
);
|
||||||
|
|
||||||
|
function Charts({ reportData }) {
|
||||||
|
const { data, query } = reportData;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="text-center text-xl text-red-700 py-10">
|
||||||
|
No Data Found
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { acceptedApplications, rejectedApplications, pendingApplications } = data;
|
||||||
|
|
||||||
|
const tableData = [];
|
||||||
|
const groupedData = {};
|
||||||
|
if (acceptedApplications) {
|
||||||
|
for (const item of acceptedApplications) {
|
||||||
|
const { institute, department, formData } = item;
|
||||||
|
const { totalExpense } = formData;
|
||||||
|
|
||||||
|
if (!groupedData[institute]) {
|
||||||
|
groupedData[institute] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.institute) {
|
||||||
|
if (!groupedData[institute][department]) {
|
||||||
|
groupedData[institute][department] = {
|
||||||
|
totalExpense: 0,
|
||||||
|
applications: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate the data
|
||||||
|
groupedData[institute][department].totalExpense +=
|
||||||
|
parseFloat(totalExpense); // Summing the expenses
|
||||||
|
groupedData[institute][department].applications += 1;
|
||||||
|
} else {
|
||||||
|
if (!groupedData[institute].applications) {
|
||||||
|
groupedData[institute] = {
|
||||||
|
totalExpense: 0,
|
||||||
|
applications: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate the data
|
||||||
|
groupedData[institute].totalExpense += parseFloat(totalExpense); // Summing the expenses
|
||||||
|
groupedData[institute].applications += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Transform grouped data into desired table format
|
||||||
|
if (query.institute) {
|
||||||
|
for (const institute in groupedData) {
|
||||||
|
for (const department in groupedData[institute]) {
|
||||||
|
const departmentData = groupedData[institute][department];
|
||||||
|
|
||||||
|
tableData.push({
|
||||||
|
id: tableData.length + 1,
|
||||||
|
Stream: department,
|
||||||
|
Scholarship: departmentData.applications, // Assuming each application is one scholarship
|
||||||
|
Purpose_of_Travel: departmentData.purposeOfTravel,
|
||||||
|
Funds: departmentData.totalExpense.toFixed(2), // Formatting funds to 2 decimal places
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const institute in groupedData) {
|
||||||
|
const instituteData = groupedData[institute];
|
||||||
|
|
||||||
|
tableData.push({
|
||||||
|
id: tableData.length + 1,
|
||||||
|
Stream: institute,
|
||||||
|
Scholarship: instituteData.applications, // Assuming each application is one scholarship
|
||||||
|
Purpose_of_Travel: instituteData.purposeOfTravel,
|
||||||
|
Funds: instituteData.totalExpense.toFixed(2), // Formatting funds to 2 decimal places
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [chartImages, setChartImages] = useState({
|
||||||
|
barChart: null,
|
||||||
|
pieChart1: null,
|
||||||
|
pieChart2: null,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line Chart Data and Options
|
||||||
|
const lineOptions = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Number of Applications Over the Years ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Year",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Number of Applications",
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineData = {
|
||||||
|
labels: [2020, 2021, 2022, 2023, 2024],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Applications",
|
||||||
|
data: [1200, 1500, 1800, 2200, 2500], // Updated data for number of applications
|
||||||
|
borderColor: "rgb(75, 192, 192)",
|
||||||
|
fill: false,
|
||||||
|
tension: 0.1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bar Chart Data and Options
|
||||||
|
const barOptions = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Number of Applications Over the Years ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Month",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Number of Applications",
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const barData = {
|
||||||
|
labels: [
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"Aug",
|
||||||
|
"Sep",
|
||||||
|
"Nov",
|
||||||
|
"Dec",
|
||||||
|
],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Applications",
|
||||||
|
data: [
|
||||||
|
1200, 1500, 1800, 2200, 200, 800, 1235, 604, 2345, 2523, 3453, 6453,
|
||||||
|
], // Updated data for number of applications
|
||||||
|
backgroundColor: "rgba(75, 192, 192, 0.5)",
|
||||||
|
borderColor: "rgb(75, 192, 192)",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pie Chart Data and Options
|
||||||
|
const pieOptions = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Purpose of Travel",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const pieData = {
|
||||||
|
labels: ["Academic", "Research", "Personal", "Other"],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: [1200, 1500, 1800, 2200], // Updated data for number of applications
|
||||||
|
backgroundColor: [
|
||||||
|
"rgba(75, 192, 192, 0.5)",
|
||||||
|
"rgba(255, 99, 132, 0.5)",
|
||||||
|
"rgba(54, 162, 235, 0.5)",
|
||||||
|
"rgba(153, 102, 255, 0.5)",
|
||||||
|
],
|
||||||
|
borderColor: [
|
||||||
|
"rgb(75, 192, 192)",
|
||||||
|
"rgb(255, 99, 132)",
|
||||||
|
"rgb(54, 162, 235)",
|
||||||
|
"rgb(153, 102, 255)",
|
||||||
|
],
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const pie_Options = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Travel",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const pie_Data = {
|
||||||
|
labels: ["Domestic", "International", "Local"],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: [1200, 1500, 1800], // Updated data for number of applications
|
||||||
|
backgroundColor: [
|
||||||
|
"rgba(79, 246, 96, 0.5)",
|
||||||
|
"rgba(255, 99, 132, 0.5)",
|
||||||
|
"rgba(54, 162, 235, 0.5)",
|
||||||
|
],
|
||||||
|
borderColor: [
|
||||||
|
"rgb(79, 246, 96)",
|
||||||
|
"rgb(255, 99, 132)",
|
||||||
|
"rgb(54, 162, 235)",
|
||||||
|
],
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// const barChartRef = useRef();
|
||||||
|
// const pieChartRef1 = useRef();
|
||||||
|
// const pieChartRef2 = useRef();
|
||||||
|
|
||||||
|
// const loadChartsInPdf = () => {
|
||||||
|
// const barChartInstance = barChartRef.current;
|
||||||
|
// const pieChartInstance1 = pieChartRef1.current;
|
||||||
|
// const pieChartInstance2 = pieChartRef2.current;
|
||||||
|
|
||||||
|
// if (barChartInstance) {
|
||||||
|
// const barBase64Image = barChartInstance.toBase64Image();
|
||||||
|
// setChartImages((prevImages) => ({
|
||||||
|
// ...prevImages,
|
||||||
|
// barChart: barBase64Image,
|
||||||
|
// }));
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (pieChartInstance1) {
|
||||||
|
// const pieBase64Image = pieChartInstance1.toBase64Image();
|
||||||
|
// setChartImages((prevImages) => ({
|
||||||
|
// ...prevImages,
|
||||||
|
// pieChart1: pieBase64Image,
|
||||||
|
// }));
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (pieChartInstance2) {
|
||||||
|
// const pieBase64Image = pieChartInstance2.toBase64Image();
|
||||||
|
// setChartImages((prevImages) => ({
|
||||||
|
// ...prevImages,
|
||||||
|
// pieChart2: pieBase64Image,
|
||||||
|
// }));
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// setChartImages((prevImages) => ({ ...prevImages, isLoading: true }));
|
||||||
|
|
||||||
|
// const handleRender = () => {
|
||||||
|
// loadChartsInPdf();
|
||||||
|
// setChartImages((prevImages) => ({ ...prevImages, isLoading: false }));
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const barChartInstance = barChartRef.current;
|
||||||
|
// const pieChartInstance1 = pieChartRef1.current;
|
||||||
|
// const pieChartInstance2 = pieChartRef2.current;
|
||||||
|
|
||||||
|
// if (barChartInstance) {
|
||||||
|
// barChartInstance.options.animation.onComplete = handleRender;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (pieChartInstance1) {
|
||||||
|
// pieChartInstance1.options.animation.onComplete = handleRender;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (pieChartInstance2) {
|
||||||
|
// pieChartInstance2.options.animation.onComplete = handleRender;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return () => {
|
||||||
|
// if (barChartInstance) {
|
||||||
|
// barChartInstance.options.animation.onComplete = null;
|
||||||
|
// }
|
||||||
|
// if (pieChartInstance1) {
|
||||||
|
// pieChartInstance1.options.animation.onComplete = null;
|
||||||
|
// }
|
||||||
|
// if (pieChartInstance2) {
|
||||||
|
// pieChartInstance2.options.animation.onComplete = null;
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
// }, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-10">
|
||||||
|
<h1 className="text-3xl mb-6">Travel Policy Report</h1>
|
||||||
|
|
||||||
|
{/* Container for all three charts */}
|
||||||
|
{/* <div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-[2fr,1fr] gap-6">
|
||||||
|
<div className="w-full">
|
||||||
|
<Bar options={barOptions} data={barData} ref={barChartRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<Pie options={pieOptions} data={pieData} ref={pieChartRef1} />
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* <div className="cards">
|
||||||
|
<Cards />
|
||||||
|
|
||||||
|
<div className="generalInfo">
|
||||||
|
<div className="card2">
|
||||||
|
<ChartWithDropdown />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* <div className="hh">
|
||||||
|
<ApprovalVsRejectionTrends />
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* Line Chart */}
|
||||||
|
{/* <div className="w-full">
|
||||||
|
<Line options={lineOptions} data={lineData} />*/}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-10 items-center justify-center my-10">
|
||||||
|
<div className="w-full">
|
||||||
|
<Table tableData={tableData} />
|
||||||
|
</div>
|
||||||
|
{/*
|
||||||
|
<div>
|
||||||
|
<Pie options={pie_Options} data={pie_Data} ref={pieChartRef2} />
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chartImages.isLoading ? (
|
||||||
|
<div className="text-center text-xl text-red-700 py-10">
|
||||||
|
Generating PDF Report...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pdfreport">
|
||||||
|
<PDFDownloadLink
|
||||||
|
document={
|
||||||
|
<ReportPDF tableData={tableData} chartImages={chartImages} />
|
||||||
|
}
|
||||||
|
fileName={`report_${query.institute || "allInstitutes"}_${
|
||||||
|
query.department || "allDepartments"
|
||||||
|
}_${query.year || "allYears"}_${
|
||||||
|
query.applicationType || "allApplications"
|
||||||
|
}.pdf`}
|
||||||
|
>
|
||||||
|
{({ blob, url, loading, error }) =>
|
||||||
|
loading ? (
|
||||||
|
<div className="text-center text-xl text-red-700 py-10">
|
||||||
|
Getting Your PDF Report Ready...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full flex items-center justify-center bg-gradient-to-r from-red-600 to-red-800 hover:from-red-800 hover:to-red-600 text-white font-semibold py-2 px-4 rounded-lg shadow-lg transform transition duration-300 ease-in-out disabled:bg-gray-400"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Download PDF
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</PDFDownloadLink>
|
||||||
|
|
||||||
|
<PDFViewer style={{ width: "70vw", height: "100vh" }}>
|
||||||
|
<ReportPDF tableData={tableData} chartImages={chartImages} />
|
||||||
|
</PDFViewer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Charts;
|
||||||
92
frontend/src/pages/Report/components/map.jsx
Normal file
92
frontend/src/pages/Report/components/map.jsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Line } from "react-chartjs-2";
|
||||||
|
import { Chart as ChartJS, LineElement, CategoryScale, LinearScale, PointElement, Title, Tooltip, Legend, Filler } from "chart.js";
|
||||||
|
|
||||||
|
// Register required Chart.js components
|
||||||
|
ChartJS.register(LineElement, CategoryScale, LinearScale, PointElement, Title, Tooltip, Legend, Filler);
|
||||||
|
|
||||||
|
const ApprovalVsRejectionTrends = () => {
|
||||||
|
// Sample data for Approved and Rejected Applications
|
||||||
|
const applicationData = {
|
||||||
|
faculty: {
|
||||||
|
approved: [100, 150, 200, 250, 300, 400, 500, 450, 600, 550, 700, 650],
|
||||||
|
rejected: [50, 60, 70, 80, 100, 90, 120, 110, 130, 100, 140, 120],
|
||||||
|
},
|
||||||
|
HOI: {
|
||||||
|
approved: [500, 600, 700, 800, 750, 700, 900, 850, 1000, 950, 1100, 1050],
|
||||||
|
rejected: [100, 120, 140, 150, 130, 110, 180, 150, 200, 170, 220, 190],
|
||||||
|
},
|
||||||
|
HOD: {
|
||||||
|
approved: [300, 400, 350, 450, 500, 480, 550, 520, 600, 580, 650, 620],
|
||||||
|
rejected: [80, 90, 100, 110, 120, 100, 140, 130, 150, 140, 160, 150],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
|
|
||||||
|
// State for selected category
|
||||||
|
const [category, setCategory] = useState("faculty");
|
||||||
|
|
||||||
|
// Data for the Line Chart
|
||||||
|
const lineChartData = {
|
||||||
|
labels: months,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Approved Applications",
|
||||||
|
data: applicationData[category].approved,
|
||||||
|
borderColor: "rgb(75, 192, 192)",
|
||||||
|
backgroundColor: "rgba(75, 192, 192, 0.2)",
|
||||||
|
tension: 0.4, // For a smooth curve
|
||||||
|
fill: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Rejected Applications",
|
||||||
|
data: applicationData[category].rejected,
|
||||||
|
borderColor: "rgb(255, 99, 132)",
|
||||||
|
backgroundColor: "rgba(255, 99, 132, 0.2)",
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineChartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, position: "top" },
|
||||||
|
title: { display: true, text: "Approval vs. Rejection Trends" },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { title: { display: true, text: "Months" } },
|
||||||
|
y: { title: { display: true, text: "Number of Applications" }, beginAtZero: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: "90%", margin: "auto", padding: "20px" }}>
|
||||||
|
<h2 style={{ textAlign: "center" }}>Approval vs. Rejection Trends</h2>
|
||||||
|
|
||||||
|
{/* Dropdown to select category */}
|
||||||
|
<div style={{ marginBottom: "20px", textAlign: "center" }}>
|
||||||
|
<label htmlFor="category-select" style={{ marginRight: "10px" }}>
|
||||||
|
Select Category:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="category-select"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
style={{ padding: "5px", fontSize: "16px", borderRadius: "8px", border: "1px solid #ccc" }}
|
||||||
|
>
|
||||||
|
<option value="faculty">Faculty</option>
|
||||||
|
<option value="HOI">HOI</option>
|
||||||
|
<option value="HOD">HOD</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line Chart */}
|
||||||
|
<Line data={lineChartData} options={lineChartOptions} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApprovalVsRejectionTrends;
|
||||||
8
frontend/src/pages/index.js
Normal file
8
frontend/src/pages/index.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { default as Login } from './login/Login'
|
||||||
|
export { default as Dashboard } from './Dashboard/Dashboard'
|
||||||
|
export { default as Applications } from './Applications/Applications'
|
||||||
|
export { default as Form } from './ApplicationForm/Form'
|
||||||
|
export { default as ApplicationView } from './ApplicationView/ApplicationView'
|
||||||
|
export { default as About } from './about/About'
|
||||||
|
export { default as Policy } from './policy/Policy'
|
||||||
|
export { default as Report } from './Report/Report'
|
||||||
52
frontend/src/services/applicationStatusAction.js
Normal file
52
frontend/src/services/applicationStatusAction.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { json } from "react-router-dom";
|
||||||
|
import { toastSuccess, toastError, toastWarning } from "../utils/toast";
|
||||||
|
|
||||||
|
export async function applicationStatusAction({ request, params }) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const action = formData.get("action")
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === "accepted") {
|
||||||
|
const expenses = JSON.parse(formData.get("expenses"));
|
||||||
|
const hasUnverifiedExpense = expenses.some(item => item?.proofStatus !== "verified");
|
||||||
|
|
||||||
|
if (hasUnverifiedExpense) {
|
||||||
|
toastWarning("Please verify all the proofs before approving");
|
||||||
|
return json(
|
||||||
|
{ message: "Please verify all the proofs before approving" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_API_URL
|
||||||
|
}/validator/statusAction`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
credentials: "include",
|
||||||
|
body: formData,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
return json({ message: "Unauthorized access" }, { status: res.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
toastError(res.statusText);
|
||||||
|
return json({ message: res.statusText }, { status: res.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
toastSuccess(`Application ${action.slice(0, 1).toUpperCase() + action.slice(1).toLowerCase()} Successfully`);
|
||||||
|
|
||||||
|
window.location.reload()
|
||||||
|
|
||||||
|
return null;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fetch error:", error);
|
||||||
|
throw json({ message: error.message }, { status: error.status || 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
49
frontend/src/services/upsertApplicationAction.js
Normal file
49
frontend/src/services/upsertApplicationAction.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { json, redirect } from 'react-router-dom';
|
||||||
|
import { toastSuccess, toastError, toastSecurityAlert } from '../utils/toast';
|
||||||
|
|
||||||
|
export async function upsertApplicationAction({ request }) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const resubmission = JSON.parse(formData.get('resubmission'));
|
||||||
|
formData.delete('resubmission');
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (resubmission) {
|
||||||
|
res = await fetch(`${import.meta.env.VITE_APP_API_URL}/applicant/resubmit-application`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await fetch(`${import.meta.env.VITE_APP_API_URL}/applicant/create-application`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
return json({ message: 'Unauthorized access' }, { status: res.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.text();
|
||||||
|
|
||||||
|
// Check for field tampering attempt
|
||||||
|
if (errorData.includes("Forbidden: Field") && errorData.includes("Tampering detected")) {
|
||||||
|
toastSecurityAlert("SECURITY ALERT: Your submission was blocked because form tampering was detected. Disabled fields cannot be modified. This incident has been logged.");
|
||||||
|
} else {
|
||||||
|
toastError(errorData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ message: errorData }, { status: res.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
toastSuccess("Application Submitted Successfully");
|
||||||
|
return redirect("../dashboard/pending");
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch error:', error);
|
||||||
|
return json({ message: error.message || 'An unexpected error occurred' }, { status: error.status || 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
58
frontend/src/services/userDataLoader.js
Normal file
58
frontend/src/services/userDataLoader.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { json, redirect } from "react-router-dom";
|
||||||
|
import { toastError } from "../utils/toast";
|
||||||
|
|
||||||
|
async function userDataLoader({ params, request }) {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${import.meta.env.VITE_APP_API_URL}/general/dataRoot`, {
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
toastError("Unauthorized Access. Please Login.");
|
||||||
|
return redirect("/"); // Redirect to login page
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRole = res.data.role;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const userRoleInURL = url.pathname.split("/")[1];
|
||||||
|
|
||||||
|
// Role-based route protection
|
||||||
|
if (userRoleInURL === "applicant" && userRole !== "Applicant") {
|
||||||
|
toastError("Access Denied: Applicant Role Required.");
|
||||||
|
return redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRoleInURL === "validator" && userRole !== "Validator") {
|
||||||
|
toastError("Access Denied: Validator Role Required.");
|
||||||
|
return redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: res.data };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Handle errors during the request
|
||||||
|
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
|
||||||
|
toastError(error.response?.data?.message || "Unauthorized Access.");
|
||||||
|
|
||||||
|
// Log out the user if unauthorized or forbidden
|
||||||
|
await fetch(`${import.meta.env.VITE_APP_API_URL}/logout`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include', // Include credentials (cookies) for logout
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect("/"); // Redirect to login page
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the error isn't related to authorization, return a network error
|
||||||
|
throw json(
|
||||||
|
{
|
||||||
|
message: error.response?.data?.message || "Network error. Please try again later.",
|
||||||
|
status: error.response?.status || 500,
|
||||||
|
},
|
||||||
|
{ status: error.response?.status || 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default userDataLoader;
|
||||||
0
frontend/src/styles/.keep
Normal file
0
frontend/src/styles/.keep
Normal file
46
frontend/src/utils/toast.js
Normal file
46
frontend/src/utils/toast.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
// Default toast configuration
|
||||||
|
const defaultConfig = {
|
||||||
|
position: "top-center",
|
||||||
|
autoClose: 4000,
|
||||||
|
hideProgressBar: false,
|
||||||
|
closeOnClick: true,
|
||||||
|
pauseOnHover: true,
|
||||||
|
draggable: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-configured toast functions
|
||||||
|
export const toastSuccess = (message) => {
|
||||||
|
return toast.success(message, {
|
||||||
|
...defaultConfig,
|
||||||
|
autoClose: 3000
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toastError = (message) => {
|
||||||
|
return toast.error(message, {
|
||||||
|
...defaultConfig,
|
||||||
|
autoClose: 5000
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toastInfo = (message) => {
|
||||||
|
return toast.info(message, {
|
||||||
|
...defaultConfig
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toastWarning = (message) => {
|
||||||
|
return toast.warning(message, {
|
||||||
|
...defaultConfig
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toastSecurityAlert = (message) => {
|
||||||
|
return toast.error(message, {
|
||||||
|
...defaultConfig,
|
||||||
|
icon: "🔐",
|
||||||
|
autoClose: 7000
|
||||||
|
});
|
||||||
|
};
|
||||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
18
frontend/vite.config.js
Normal file
18
frontend/vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/applicant-login': process.env.VITE_APP_API_URL,
|
||||||
|
'/validator-login': process.env.VITE_APP_API_URL,
|
||||||
|
'/verify-applicant': process.env.VITE_APP_API_URL,
|
||||||
|
'/verify-validator': process.env.VITE_APP_API_URL,
|
||||||
|
'/submit': process.env.VITE_APP_API_URL,
|
||||||
|
},
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user