diff --git a/client/package.json b/client/package.json index 102348e2b..f9599a871 100644 --- a/client/package.json +++ b/client/package.json @@ -3,22 +3,21 @@ "version": "0.1.0", "private": true, "dependencies": { - "@testing-library/dom": "^10.4.0", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.2.0", - "@testing-library/user-event": "^13.5.0", - "axios": "^1.8.2", - "bootstrap": "^5.3.5", - "react": "^19.0.0", - "react-bootstrap": "^2.10.9", - "react-dom": "^19.0.0", - "react-router-dom": "^7.4.1", - "react-scripts": "5.0.1", - "react-icons": "^5.5.0", - "react-signature-canvas": "^1.1.0-alpha.2", - "web-vitals": "^2.1.4" -}, - + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^13.5.0", + "axios": "^1.8.2", + "bootstrap": "^5.3.5", + "react": "^19.0.0", + "react-bootstrap": "^2.10.9", + "react-dom": "^19.0.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.4.1", + "react-scripts": "5.0.1", + "react-signature-canvas": "^1.1.0-alpha.2", + "web-vitals": "^2.1.4" + }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", diff --git a/client/src/App.js b/client/src/App.js index aec9d81c4..67991b7cb 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -1,5 +1,6 @@ import React from 'react'; import { RouterProvider } from "react-router-dom"; + import router from "./router"; import "./styles/App.css"; diff --git a/client/src/pages/A1InternshipRequestForm.js b/client/src/pages/A1InternshipRequestForm.js new file mode 100644 index 000000000..a7b719ce9 --- /dev/null +++ b/client/src/pages/A1InternshipRequestForm.js @@ -0,0 +1,356 @@ +import React, { useState } from 'react'; +import '../styles/A1InternshipRequestForm.css'; + +const A1InternshipRequestForm = () => { + const initialState = { + interneeName: '', + soonerId: '', + interneeEmail: '', + workplaceName: '', + website: '', + phone: '', + startDate: '', + endDate: '', + advisorName: '', + advisorJobTitle: '', + advisorEmail: '', + interneeSignature: '', + advisorSignature: '', + coordinatorApproval: '', + creditHours: '', + tasks: ['', '', '', '', ''], + outcomes: Array(5).fill(Array(6).fill(false)), + }; + + const [formData, setFormData] = useState(initialState); + const [successMsg, setSuccessMsg] = useState(''); + const [errorMsg, setErrorMsg] = useState(''); + const [dateError, setDateError] = useState(''); + + const handleInputChange = (e) => { + const { id, value } = e.target; + setFormData((prev) => ({ ...prev, [id]: value })); + + // Clear date error when either date field changes + if (id === 'startDate' || id === 'endDate') { + setDateError(''); + + // Validate dates when both are filled + if (formData.startDate && formData.endDate) { + validateDates(id === 'startDate' ? value : formData.startDate, + id === 'endDate' ? value : formData.endDate); + } + } + }; + + const validateDates = (start, end) => { + const startDate = new Date(start); + const endDate = new Date(end); + + if (endDate <= startDate) { + setDateError('End date must be after start date'); + } else { + setDateError(''); + } + }; + + const handleCreditHourChange = (value) => { + setFormData((prev) => ({ ...prev, creditHours: value })); + }; + + const handleTaskChange = (index, value) => { + const updatedTasks = [...formData.tasks]; + updatedTasks[index] = value; + setFormData((prev) => ({ ...prev, tasks: updatedTasks })); + }; + + const handleOutcomeChange = (taskIndex, outcomeIndex) => { + const updatedOutcomes = formData.outcomes.map((row, i) => + i === taskIndex + ? row.map((val, j) => (j === outcomeIndex ? !val : val)) + : row + ); + setFormData((prev) => ({ ...prev, outcomes: updatedOutcomes })); + }; + + const validateForm = () => { + const namePattern = /^[A-Za-z\s]+$/; + const numberPattern = /^[0-9]+$/; + const phonePattern = /^[0-9]{10}$/; + const emailPattern = /^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$/; + + const { + interneeName, soonerId, interneeEmail, workplaceName, phone, + startDate, endDate, advisorName, advisorEmail, + interneeSignature, advisorSignature, coordinatorApproval, + creditHours, tasks, outcomes + } = formData; + + const requiredFieldsFilled = interneeName && soonerId && interneeEmail && + workplaceName && phone && startDate && endDate && + advisorName && advisorEmail && interneeSignature && + advisorSignature && coordinatorApproval && creditHours; + + const patternsValid = namePattern.test(interneeName) && + numberPattern.test(soonerId) && + emailPattern.test(interneeEmail) && + namePattern.test(workplaceName) && + phonePattern.test(phone) && + namePattern.test(advisorName) && + emailPattern.test(advisorEmail) && + namePattern.test(interneeSignature) && + namePattern.test(advisorSignature) && + namePattern.test(coordinatorApproval); + + const tasksFilled = tasks.every(task => task.trim() !== ''); + + const start = new Date(startDate); + const end = new Date(endDate); + const datesValid = end > start; + + if (!datesValid) { + setDateError('End date must be after start date'); + return false; + } + + const outcomesValid = outcomes.every(taskOutcomes => + taskOutcomes.filter(val => val).length >= 4 + ); + + return requiredFieldsFilled && patternsValid && tasksFilled && datesValid && outcomesValid; + }; + + const handleSubmit = (e) => { + e.preventDefault(); + const isValid = validateForm(); + + if (isValid) { + setSuccessMsg('Form submitted successfully!'); + setErrorMsg(''); + submitFormData(formData); + setTimeout(() => setSuccessMsg(''), 3000); + setFormData(initialState); + } else { + setErrorMsg('Please fill all required fields with valid data. Each task must have at least 4 outcomes selected.'); + setSuccessMsg(''); + } + }; + const submitFormData = async () => { + const outcomeMap = { + 0: 'problemSolving', + 1: 'solutionDevelopment', + 2: 'communication', + 3: 'decisionMaking', + 4: 'collaboration', + 5: 'application' + }; + + const tasksWithOutcomes = formData.tasks.map((taskDesc, i) => { + const selectedOutcomes = formData.outcomes[i] + .map((checked, j) => (checked ? outcomeMap[j] : null)) + .filter(Boolean); + return { + description: taskDesc.trim(), + outcomes: selectedOutcomes + }; + }); + + const payload = { + interneeName: formData.interneeName.trim(), + soonerId: formData.soonerId.trim(), + interneeEmail: formData.interneeEmail.trim(), + workplaceName: formData.workplaceName.trim(), + website: formData.website.trim(), + phone: formData.phone.trim(), + startDate: formData.startDate, + endDate: formData.endDate, + advisorName: formData.advisorName.trim(), + advisorJobTitle: formData.advisorJobTitle.trim(), + advisorEmail: formData.advisorEmail.trim(), + interneeSignature: formData.interneeSignature.trim(), + advisorSignature: formData.advisorSignature.trim(), + coordinatorApproval: formData.coordinatorApproval.trim(), + creditHour: formData.creditHours, + tasks: tasksWithOutcomes + }; + + try { + const response = await fetch("http://localhost:5001/api/form/submit", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + await response.json(); + } catch (err) { + console.error(err); + } +}; + + + return ( +
+

A.1 - Internship Request Form

+

Internee & Workplace Information:

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {[1, 2, 3].map((val) => ( + + ))} + + + +
Internee DetailsWorkplace DetailsInternship Advisor Details
Name:
Name:
Name:
Sooner ID:
Website:
Job Title:
Email:
Phone:
Email:
Select the Number of Credit Hours + Start Date:
+ +
+ End Date:
+ + {dateError &&
{dateError}
} +
+ {val}
+ handleCreditHourChange(val.toString())} + /> +
+ + {/* Tasks and Outcomes Section */} +

Task Details & Program Outcomes:

+ + + + + + + + + + + + + + + + + + {formData.tasks.map((task, i) => ( + + + {formData.outcomes[i].map((outcome, j) => ( + + ))} + + ))} + +
Job Description DetailsProgram Outcome
+
    +
  1. Tasks need to be filled by the Internship Advisor.
  2. +
  3. Select one or more outcomes per task.
  4. +
  5. All tasks must cover at least 4 outcomes.
  6. +
+
Problem SolvingSolution DevelopmentCommunicationDecision-MakingCollaborationApplication
+ Task {i + 1}:
+ handleTaskChange(i, e.target.value)} + className="task" + /> +
+ handleOutcomeChange(i, j)} + className="outcome" + /> +
+ + {/* Signatures */} +

Signatures:

+ + + + + + + + +
+ Internee Signature
+ +
+ Internship Advisor Signature
+ +
+ Internship Coordinator Approval
+ +
+ +
+ +
+ {successMsg &&
{successMsg}
} + {errorMsg &&
{errorMsg}
} +
+
+ ); +}; + +export default A1InternshipRequestForm; \ No newline at end of file diff --git a/client/src/pages/Home.js b/client/src/pages/Home.js index c9334218b..449478f73 100644 --- a/client/src/pages/Home.js +++ b/client/src/pages/Home.js @@ -172,4 +172,4 @@ function Home() { ); } -export default Home; +export default Home; \ No newline at end of file diff --git a/client/src/pages/SignUp.js b/client/src/pages/SignUp.js index 085a8277c..5f4a42166 100644 --- a/client/src/pages/SignUp.js +++ b/client/src/pages/SignUp.js @@ -354,4 +354,4 @@ function SignUp() { ); } -export default SignUp; +export default SignUp; \ No newline at end of file diff --git a/client/src/router.js b/client/src/router.js index 69479cdfd..3664e5fcf 100644 --- a/client/src/router.js +++ b/client/src/router.js @@ -1,6 +1,8 @@ import React from "react"; import { createBrowserRouter } from "react-router-dom"; +import A1InternshipRequestForm from "./pages/A1InternshipRequestForm"; + // Layout import Layout from "./components/Layout"; @@ -28,6 +30,10 @@ const router = createBrowserRouter([ path: "signup", element: , }, + { + path: "a1-form", + element: , + }, { path: "evaluation", element: , diff --git a/client/src/styles/A1InternshipRequestForm.css b/client/src/styles/A1InternshipRequestForm.css new file mode 100644 index 000000000..602322465 --- /dev/null +++ b/client/src/styles/A1InternshipRequestForm.css @@ -0,0 +1,118 @@ +.form-container { + font-family: 'Roboto', sans-serif; + margin: 30px; + background-color: #f5f5f5; + padding: 20px; + } + + h2 { + text-align: center; + color: #841617; + } + + .section-title { + font-size: 16px; + color: #841617; + margin-top: 30px; + margin-bottom: 10px; + font-weight: bold; + } + + table { + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + background-color: #fff; + } + + th { + background-color: #841617; + color: #fff; + } + + th, td { + padding: 8px; + border: 1px solid #999; + vertical-align: top; + } + + input[type="text"], + input[type="email"], + input[type="date"] { + width: 95%; + padding: 6px; + font-size: 13px; + border: 1px solid #999; + border-radius: 3px; + } + + input[type="checkbox"] { + appearance: none; + width: 16px; + height: 16px; + border: 2px solid #333; + border-radius: 50%; + background-color: white; + cursor: pointer; + } + + input[type="checkbox"]:checked { + background-color: #841617; + } + + .signature-cell { + text-align: center; + background-color: #841617; + color: #fff; + } + + .signature-cell input { + margin-top: 6px; + padding: 6px; + width: 90%; + } + + span.description { + font-size: 11.5px; + color: #fff; + display: block; + margin-top: 4px; + } + + ol { + padding-left: 18px; + font-size: 13px; + } + + .submit-section { + text-align: center; + margin-top: 20px; + } + + button { + padding: 10px 25px; + background-color: #841617; + color: white; + border: none; + font-size: 14px; + border-radius: 5px; + cursor: pointer; + } + + button:hover { + background-color: #6e1212; + } + + .success-msg { + color: green; + font-weight: bold; + text-align: center; + margin-top: 10px; + } + + .error-msg { + color: red; + font-weight: bold; + text-align: center; + margin-top: 10px; + } \ No newline at end of file diff --git a/server/.env b/server/.env index 32d995d42..db7ef5551 100644 --- a/server/.env +++ b/server/.env @@ -2,6 +2,8 @@ PORT=5001 MONGO_URI=mongodb://localhost:27017/IPMS + + # Email Configuration EMAIL_HOST=smtp.gmail.com EMAIL_PORT=587 diff --git a/server/index.js b/server/index.js index cddf72aa6..d431a6aaf 100644 --- a/server/index.js +++ b/server/index.js @@ -2,6 +2,8 @@ const express = require("express"); const mongoose = require("mongoose"); const cors = require("cors"); const User = require("./models/User"); +const formRoutes = require("./routes/formRoutes"); + require("dotenv").config(); const emailRoutes = require("./routes/emailRoutes"); @@ -16,6 +18,7 @@ const Evaluation = require("./models/Evaluation"); const app = express(); app.use(express.json()); app.use(cors()); +app.use("/api/form", formRoutes); // register route as /api/form/submit const mongoConfig = { serverSelectionTimeoutMS: 5000, diff --git a/server/models/InternshipRequest.js b/server/models/InternshipRequest.js new file mode 100644 index 000000000..b22a5ac62 --- /dev/null +++ b/server/models/InternshipRequest.js @@ -0,0 +1,72 @@ +const mongoose = require("mongoose"); // why are we commonjs +const ObjectId = mongoose.Schema.Types.ObjectId; + +const Task = new mongoose.Schema({ + _id: false, + description: { + type: String, + required: true + }, + outcomes: { + type: [String], + enum: ['problemSolving','solutionDevelopment', 'communication', 'decisionMaking', 'collaboration', 'application'] + } +}); +const formA1 = new mongoose.Schema({ + student: { // get student's name, email, id from User + type: ObjectId, + required: true, + ref: 'User' + }, + workplace: { + name: { + type: String, + required: true, + }, + website: String, + phone: String, // TODO how to validate this? + }, + internshipAdvisor: { + name: String, + jobTitle: String, + email: { + type: String, + required: true + } + }, + creditHours: { + type: Number, + required: true, + enum: [1, 2, 3] + }, + startDate: { + type: Date, + required: true + }, + endDate: { // TODO how to make sure endDate is later than startDate? + type: Date, + required: true + }, + tasks: { + type: [Task], + required: true + }, + status: { + type: String, + required: true, + enum: ['draft', 'submitted', 'approved'] + }, + approvals: { + type: [String], + enum: ['advisor', 'coordinator'] + }, + reminders: [Date], + // requiredHours is an easily derived attribute + // TODO needs to be a virtual getter that checks this student's WeeklyReports + completedHours: Number +}, { timestamps: true }); +formA1.virtual("requiredHours").get(function() { + return this.creditHours * 60; +}) + +module.exports = mongoose.model("InternshipRequest", formA1); \ No newline at end of file diff --git a/server/routes/formRoutes.js b/server/routes/formRoutes.js new file mode 100644 index 000000000..a85b47876 --- /dev/null +++ b/server/routes/formRoutes.js @@ -0,0 +1,56 @@ +const express = require("express"); +const router = express.Router(); +const { insertFormData } = require("../services/insertData"); + +// Utility: Validate required fields +function validateFormData(formData) { + const requiredFields = [ + "workplaceName", + "website", + "phone", + "advisorName", + "advisorJobTitle", + "advisorEmail", + "creditHour", + "startDate", + "endDate", + "tasks" + ]; + + for (const field of requiredFields) { + if (!formData[field] || formData[field] === "") { + return `Missing or empty required field: ${field}`; + } + } + + if (!Array.isArray(formData.tasks) || formData.tasks.length === 0) { + return "Tasks must be a non-empty array"; + } + + for (const [index, task] of formData.tasks.entries()) { + if (!task.description || !task.outcomes) { + return `Task at index ${index} is missing description or outcomes`; + } + } + + return null; // No errors +} + +router.post("/submit", async (req, res) => { + const formData = req.body; + + const validationError = validateFormData(formData); + if (validationError) { + return res.status(400).json({ message: validationError }); + } + + try { + await insertFormData(formData); + res.status(200).json({ message: "Form received and handled!" }); + } catch (error) { + console.error("Error handling form data:", error); + res.status(500).json({ message: "Something went wrong" }); + } +}); + +module.exports = router; diff --git a/server/services/insertData.js b/server/services/insertData.js new file mode 100644 index 000000000..801af75f3 --- /dev/null +++ b/server/services/insertData.js @@ -0,0 +1,47 @@ +const mongoose = require("mongoose"); +const InternshipRequest = require("../models/InternshipRequest"); + +async function insertFormData(formData) { + try { + console.log("Received Form Data:\n", JSON.stringify(formData, null, 2)); + + // Assumes global mongoose connection is already established elsewhere in app + + const formattedData = { + student: new mongoose.Types.ObjectId(), // TODO: Replace with actual signed-in student ID + workplace: { + name: formData.workplaceName, + website: formData.website, + phone: formData.phone, + }, + internshipAdvisor: { + name: formData.advisorName, + jobTitle: formData.advisorJobTitle, + email: formData.advisorEmail, + }, + creditHours: parseInt(formData.creditHour), + startDate: new Date(formData.startDate), + endDate: new Date(formData.endDate), + tasks: formData.tasks.map(task => ({ + description: task.description, + outcomes: task.outcomes, + })), + status: "submitted", // Default status — adjust as needed + approvals: ["advisor", "coordinator"], // TODO: Might be dynamic later + reminders: [], // Placeholder for future reminder logic + completedHours: parseInt(formData.creditHour) * 60, // Assuming 1 credit = 60 hours + }; + + const savedForm = await InternshipRequest.create(formattedData); + console.log("Form saved successfully with ID:", savedForm._id); + return savedForm; + + } catch (error) { + console.error("Error saving form:", error.message); + throw error; + } +} + +module.exports = { + insertFormData, +};