From 834eaad3c34812e68a8c776b65fe6dccac6bddb3 Mon Sep 17 00:00:00 2001 From: ARJUN S THAMPI <61703062+arjun-thampi@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:12:04 +0530 Subject: [PATCH 1/4] feat:add email send functionality --- backend/package-lock.json | 147 +++++++++++- backend/package.json | 1 + .../migration.sql | 12 + backend/prisma/schema.prisma | 12 + backend/src/app.js | 2 + .../src/controllers/appointment.controller.js | 26 ++- .../src/controllers/emailConfig.controller.js | 139 +++++++++++ backend/src/routes/emailConfig.routes.js | 19 ++ backend/src/utils/getEmailByTypes.js | 17 ++ backend/src/utils/sendEmail.js | 18 ++ frontend/package-lock.json | 113 ++++++++- frontend/package.json | 4 +- frontend/src/App.tsx | 2 + frontend/src/api/appointment.ts | 11 + frontend/src/components/layout/Sidebar.tsx | 4 + frontend/src/pages/Appointment.tsx | 216 ++++++++++++++++++ frontend/src/utils/exportToExcel.ts | 22 ++ 17 files changed, 759 insertions(+), 6 deletions(-) create mode 100644 backend/prisma/migrations/20260319060901_add_email_config/migration.sql create mode 100644 backend/src/controllers/emailConfig.controller.js create mode 100644 backend/src/routes/emailConfig.routes.js create mode 100644 backend/src/utils/getEmailByTypes.js create mode 100644 backend/src/utils/sendEmail.js create mode 100644 frontend/src/api/appointment.ts create mode 100644 frontend/src/pages/Appointment.tsx create mode 100644 frontend/src/utils/exportToExcel.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index b5e59b1..fe3ad00 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,6 +21,7 @@ "express-session": "^1.19.0", "jsonwebtoken": "^9.0.3", "multer": "^2.1.1", + "postmark": "^4.0.7", "prisma": "^6.19.2" }, "devDependencies": { @@ -211,6 +212,23 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -482,6 +500,18 @@ "integrity": "sha512-IuA8LeyLU5p1B+HyhOsqR6oxyFQ11k3i9e9aXw40CrHFTRO2Y1npNBVU3W1SvhKAbUU7R/YikUBdcYFP0RcJag==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -601,6 +631,15 @@ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -715,6 +754,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -879,6 +933,63 @@ "url": "https://opencollective.com/express" } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1022,6 +1133,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1294,9 +1420,9 @@ } }, "node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -1586,6 +1712,15 @@ "pathe": "^2.0.3" } }, + "node_modules/postmark": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/postmark/-/postmark-4.0.7.tgz", + "integrity": "sha512-DjNniUl1XNCGUKhCR98ePd5gv16rlUAVKKaU9TUqnE3hDSqfT9XDulu1idjagQmdyGscqnRtXk/puAEiYMeevg==", + "license": "MIT", + "dependencies": { + "axios": "^1.13.5" + } + }, "node_modules/prisma": { "version": "6.19.2", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", @@ -1625,6 +1760,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", diff --git a/backend/package.json b/backend/package.json index 9bdadf6..4f05cfd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,6 +27,7 @@ "express-session": "^1.19.0", "jsonwebtoken": "^9.0.3", "multer": "^2.1.1", + "postmark": "^4.0.7", "prisma": "^6.19.2" }, "devDependencies": { diff --git a/backend/prisma/migrations/20260319060901_add_email_config/migration.sql b/backend/prisma/migrations/20260319060901_add_email_config/migration.sql new file mode 100644 index 0000000..df27c9d --- /dev/null +++ b/backend/prisma/migrations/20260319060901_add_email_config/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "EmailConfig" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "type" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EmailConfig_pkey" PRIMARY KEY ("id") +); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8ee9475..e659495 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -173,4 +173,16 @@ model AcademicsResearch { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt +} + + +model EmailConfig { + id Int @id @default(autoincrement()) + name String + email String + type String + isActive Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } \ No newline at end of file diff --git a/backend/src/app.js b/backend/src/app.js index 4ad4bbf..aa3c088 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -12,6 +12,7 @@ import candidateRoutes from "./routes/candidate.routes.js"; import appointmentRoutes from "./routes/appointment.routes.js"; import inquiryRoutes from "./routes/inquiry.routes.js"; import academicsResearchRoutes from "./routes/academicsResearch.routes.js"; +import emailConfigRoutes from "./routes/emailConfig.routes.js"; dotenv.config(); @@ -47,6 +48,7 @@ app.use("/api/candidates", candidateRoutes); app.use("/api/appointments", appointmentRoutes); app.use("/api/inquiry", inquiryRoutes); app.use("/api/academics", academicsResearchRoutes); +app.use("/api/email", emailConfigRoutes); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { diff --git a/backend/src/controllers/appointment.controller.js b/backend/src/controllers/appointment.controller.js index cecbb94..3984c17 100644 --- a/backend/src/controllers/appointment.controller.js +++ b/backend/src/controllers/appointment.controller.js @@ -1,5 +1,6 @@ import prisma from "../prisma/client.js"; -//CREATE APPOINTMENT +import {sendEmail} from "../utils/sendEmail.js"; +import {getEmailsByType} from "../utils/getEmailByTypes.js"; export const createAppointment = async (req, res) => { try { @@ -29,6 +30,29 @@ export const createAppointment = async (req, res) => { }, }); + try { + const emailList = await getEmailsByType("APPOINTMENT"); + + if (emailList) { + await sendEmail({ + to: emailList, + subject: "New Appointment Booked", + html: ` +

New Appointment Booked

+

Name: ${name}

+

Phone: ${mobileNumber}

+

Email: ${email || "-"}

+

Doctor: ${appointment.doctor?.name}

+

Department: ${appointment.department?.name}

+

Date: ${new Date(date).toLocaleDateString()}

+

Message: ${message || "-"}

+ `, + }); + } + } catch (err) { + console.error("Email failed:", err); + } + res.status(201).json({ success: true, message: "Appointment booked successfully", diff --git a/backend/src/controllers/emailConfig.controller.js b/backend/src/controllers/emailConfig.controller.js new file mode 100644 index 0000000..d09678c --- /dev/null +++ b/backend/src/controllers/emailConfig.controller.js @@ -0,0 +1,139 @@ +import prisma from "../prisma/client.js"; + +// CREATE +export const createEmailConfig = async (req, res) => { + try { + const {name, email, type, isActive} = req.body; + + if (!name || !email || !type) { + return res.status(400).json({ + success: false, + message: "Name, Email and Type are required", + }); + } + + const newEmail = await prisma.emailConfig.create({ + data: { + name, + email, + type, + isActive: isActive ?? true, + }, + }); + + res.status(201).json({ + success: true, + message: "Email config created", + data: newEmail, + }); + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: "Failed to create email config", + }); + } +}; + +//GET ALL +export const getEmailConfigs = async (req, res) => { + try { + const emails = await prisma.emailConfig.findMany({ + orderBy: { + createdAt: "desc", + }, + }); + + res.status(200).json({ + success: true, + data: emails, + }); + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: "Failed to fetch email configs", + }); + } +}; + +// GET SINGLE +export const getEmailConfig = async (req, res) => { + try { + const {id} = req.params; + + const email = await prisma.emailConfig.findUnique({ + where: { + id: Number(id), + }, + }); + + if (!email) { + return res.status(404).json({ + success: false, + message: "Email config not found", + }); + } + + res.status(200).json({ + success: true, + data: email, + }); + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: "Failed to fetch email config", + }); + } +}; + +// UPDATE +export const updateEmailConfig = async (req, res) => { + try { + const {id} = req.params; + + const updated = await prisma.emailConfig.update({ + where: { + id: Number(id), + }, + data: req.body, + }); + + res.status(200).json({ + success: true, + message: "Email config updated", + data: updated, + }); + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: "Failed to update email config", + }); + } +}; + +// DELETE +export const deleteEmailConfig = async (req, res) => { + try { + const {id} = req.params; + + await prisma.emailConfig.delete({ + where: { + id: Number(id), + }, + }); + + res.status(200).json({ + success: true, + message: "Email config deleted", + }); + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: "Failed to delete email config", + }); + } +}; diff --git a/backend/src/routes/emailConfig.routes.js b/backend/src/routes/emailConfig.routes.js new file mode 100644 index 0000000..1308e89 --- /dev/null +++ b/backend/src/routes/emailConfig.routes.js @@ -0,0 +1,19 @@ +import express from "express"; +import { + getEmailConfigs, + createEmailConfig, + updateEmailConfig, + deleteEmailConfig, +} from "../controllers/emailConfig.controller.js"; + +import jwtAuthMiddleware from "../middleware/auth.js"; + +const router = express.Router(); + +router.get("/getAll", getEmailConfigs); + +router.post("/", jwtAuthMiddleware, createEmailConfig); +router.put("/:id", jwtAuthMiddleware, updateEmailConfig); +router.delete("/:id", jwtAuthMiddleware, deleteEmailConfig); + +export default router; diff --git a/backend/src/utils/getEmailByTypes.js b/backend/src/utils/getEmailByTypes.js new file mode 100644 index 0000000..0a76d89 --- /dev/null +++ b/backend/src/utils/getEmailByTypes.js @@ -0,0 +1,17 @@ +import prisma from "../prisma/client.js"; + +export const getEmailsByType = async (type) => { + try { + const emails = await prisma.emailConfig.findMany({ + where: { + type, + isActive: true, + }, + }); + + return emails.map((e) => e.email).join(","); + } catch (error) { + console.error("Fetch email config error:", error); + return ""; + } +}; diff --git a/backend/src/utils/sendEmail.js b/backend/src/utils/sendEmail.js new file mode 100644 index 0000000..e88e673 --- /dev/null +++ b/backend/src/utils/sendEmail.js @@ -0,0 +1,18 @@ +import postmark from "postmark"; + +const client = new postmark.ServerClient(process.env.POSTMARK_API_KEY); + +export const sendEmail = async ({to, subject, html, text}) => { + try { + await client.sendEmail({ + From: process.env.EMAIL_FROM, + To: to, + Subject: subject, + HtmlBody: html, + TextBody: text || "", + MessageStream: "outbound", + }); + } catch (error) { + console.error("Email send error:", error); + } +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f1a50aa..e1bb47c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "file-saver": "^2.0.5", "lucide-react": "^0.577.0", "radix-ui": "^1.4.3", "react": "^19.2.0", @@ -32,7 +33,8 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", "tailwindcss-animate": "^1.0.7", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -4484,6 +4486,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4814,6 +4825,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4982,6 +5006,15 @@ "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", "license": "MIT" }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/codex-notifier": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/codex-notifier/-/codex-notifier-1.1.2.tgz", @@ -5120,6 +5153,18 @@ } } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5964,6 +6009,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -6113,6 +6164,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -8684,6 +8744,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -9336,6 +9408,24 @@ "node": ">= 8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -9423,6 +9513,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3c87900..a97269e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "file-saver": "^2.0.5", "lucide-react": "^0.577.0", "radix-ui": "^1.4.3", "react": "^19.2.0", @@ -34,7 +35,8 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", "tailwindcss-animate": "^1.0.7", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dbdd97b..e73b2d4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import Department from "./pages/Department"; import Doctor from "./pages/Doctor"; import Blog from "./pages/Blog"; import BlogEditorPage from "./pages/BlogEditor"; +import Appointment from "./pages/Appointment"; export default function App() { return ( @@ -30,6 +31,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/appointment.ts b/frontend/src/api/appointment.ts new file mode 100644 index 0000000..c8ce146 --- /dev/null +++ b/frontend/src/api/appointment.ts @@ -0,0 +1,11 @@ +import apiClient from "@/api/client"; + +export const getAppointmentsApi = async () => { + const res = await apiClient.get("/appointments/getall"); + return res.data; +}; + +export const deleteAppointmentApi = async (id: number) => { + const res = await apiClient.delete(`/appointments/${id}`); + return res.data; +}; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 365ed31..384069a 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -15,6 +15,10 @@ export default function Sidebar() { name: "Doctor", path: "/doctor", }, + { + name: "Appointments", + path: "/appointment", + }, { name: "Blog", path: "/blog", diff --git a/frontend/src/pages/Appointment.tsx b/frontend/src/pages/Appointment.tsx new file mode 100644 index 0000000..4eb44d6 --- /dev/null +++ b/frontend/src/pages/Appointment.tsx @@ -0,0 +1,216 @@ +import {useState, useEffect, useCallback} from "react"; + +import {getAppointmentsApi, deleteAppointmentApi} from "@/api/appointment"; +import {exportToExcel} from "@/utils/exportToExcel"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; + +import {Button} from "@/components/ui/button"; +import {Input} from "@/components/ui/input"; + +import {Loader2, Trash, RefreshCw, Download} from "lucide-react"; + +export default function AppointmentPage() { + const [appointments, setAppointments] = useState([]); + const [loading, setLoading] = useState(true); + + const [searchText, setSearchText] = useState(""); + const [filterDoctor, setFilterDoctor] = useState(""); + const [filterDepartment, setFilterDepartment] = useState(""); + const [filterDate, setFilterDate] = useState(""); + + const fetchAll = useCallback(async () => { + setLoading(true); + try { + const res = await getAppointmentsApi(); + setAppointments(res?.data || []); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchAll(); + }, [fetchAll]); + + const filteredAppointments = appointments.filter((item) => { + const matchesSearch = + item.name?.toLowerCase().includes(searchText.toLowerCase()) || + item.mobileNumber?.includes(searchText) || + item.email?.toLowerCase().includes(searchText.toLowerCase()); + + const matchesDoctor = filterDoctor + ? item.doctor?.name?.toLowerCase().includes(filterDoctor.toLowerCase()) + : true; + + const matchesDepartment = filterDepartment + ? item.department?.name + ?.toLowerCase() + .includes(filterDepartment.toLowerCase()) + : true; + + const matchesDate = filterDate + ? new Date(item.date).toISOString().split("T")[0] === filterDate + : true; + + return matchesSearch && matchesDoctor && matchesDepartment && matchesDate; + }); + + async function handleDelete(id: number) { + if (!confirm("Delete appointment?")) return; + await deleteAppointmentApi(id); + fetchAll(); + } + + const handleExport = () => { + const exportData = filteredAppointments.map((item) => ({ + ID: item.id, + Name: item.name, + Phone: item.mobileNumber, + Email: item.email, + Doctor: item.doctor?.name, + Department: item.department?.name, + Date: new Date(item.date).toLocaleDateString(), + Message: item.message, + })); + + exportToExcel(exportData, "appointments"); + }; + + return ( +
+
+

Appointments

+ +
+ setSearchText(e.target.value)} + className="w-[220px]" + /> + + setFilterDoctor(e.target.value)} + className="w-[180px]" + /> + + setFilterDepartment(e.target.value)} + className="w-[200px]" + /> + + setFilterDate(e.target.value)} + className="w-[180px]" + /> + + + + +
+
+ + + + Appointment List + + + +
+ + + + ID + Name + Phone + Email + Doctor + Department + Appointment Date + Message + Generated on + + Actions + + + + + {loading ? ( + + + + + + ) : filteredAppointments.length === 0 ? ( + + + No appointments found + + + ) : ( + filteredAppointments.map((item) => ( + + {item.id} + {item.name} + {item.mobileNumber} + {item.email} + {item.doctor?.name} + {item.department?.name} + + {/* ✅ DATE ONLY */} + + {new Date(item.date).toLocaleDateString()} + + + + {item.message} + + + {" "} + {new Date(item.createdAt).toLocaleDateString()} + + + + + + + )) + )} + +
+
+
+
+
+ ); +} diff --git a/frontend/src/utils/exportToExcel.ts b/frontend/src/utils/exportToExcel.ts new file mode 100644 index 0000000..b8873d2 --- /dev/null +++ b/frontend/src/utils/exportToExcel.ts @@ -0,0 +1,22 @@ +import * as XLSX from "xlsx"; +import {saveAs} from "file-saver"; + +export const exportToExcel = (data: any[], fileName: string = "data") => { + if (!data || data.length === 0) return; + + const worksheet = XLSX.utils.json_to_sheet(data); + + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1"); + + const excelBuffer = XLSX.write(workbook, { + bookType: "xlsx", + type: "array", + }); + + const blob = new Blob([excelBuffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8", + }); + + saveAs(blob, `${fileName}.xlsx`); +}; From 6e999c36c576a453cc8c8c070aad6f5554172da8 Mon Sep 17 00:00:00 2001 From: ARJUN S THAMPI <61703062+arjun-thampi@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:41:46 +0530 Subject: [PATCH 2/4] feat: add email page --- backend/src/routes/emailConfig.routes.js | 2 +- frontend/src/App.tsx | 2 + frontend/src/api/email.ts | 36 +++ frontend/src/components/layout/Sidebar.tsx | 4 + frontend/src/pages/email.tsx | 265 +++++++++++++++++++++ 5 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 frontend/src/api/email.ts create mode 100644 frontend/src/pages/email.tsx diff --git a/backend/src/routes/emailConfig.routes.js b/backend/src/routes/emailConfig.routes.js index 1308e89..eec6dde 100644 --- a/backend/src/routes/emailConfig.routes.js +++ b/backend/src/routes/emailConfig.routes.js @@ -13,7 +13,7 @@ const router = express.Router(); router.get("/getAll", getEmailConfigs); router.post("/", jwtAuthMiddleware, createEmailConfig); -router.put("/:id", jwtAuthMiddleware, updateEmailConfig); +router.patch("/:id", jwtAuthMiddleware, updateEmailConfig); router.delete("/:id", jwtAuthMiddleware, deleteEmailConfig); export default router; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e73b2d4..9a1e549 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import Doctor from "./pages/Doctor"; import Blog from "./pages/Blog"; import BlogEditorPage from "./pages/BlogEditor"; import Appointment from "./pages/Appointment"; +import EmailPage from "./pages/email"; export default function App() { return ( @@ -32,6 +33,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/email.ts b/frontend/src/api/email.ts new file mode 100644 index 0000000..ff9f7eb --- /dev/null +++ b/frontend/src/api/email.ts @@ -0,0 +1,36 @@ +import apiClient from "@/api/client"; + +export interface EmailConfig { + id?: number; + name: string; + email: string; + type: string; + isActive?: boolean; +} + +// GET ALL +export const getEmailConfigsApi = async () => { + const res = await apiClient.get("/email/getAll"); + return res.data; +}; + +// CREATE +export const createEmailConfigApi = async (data: EmailConfig) => { + const res = await apiClient.post("/email", data); + return res.data; +}; + +// UPDATE +export const updateEmailConfigApi = async ( + id: number, + data: Partial, +) => { + const res = await apiClient.patch(`/email/${id}`, data); + return res.data; +}; + +// DELETE +export const deleteEmailConfigApi = async (id: number) => { + const res = await apiClient.delete(`/email/${id}`); + return res.data; +}; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 384069a..a36c7df 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -19,6 +19,10 @@ export default function Sidebar() { name: "Appointments", path: "/appointment", }, + { + name: "Email", + path: "/email", + }, { name: "Blog", path: "/blog", diff --git a/frontend/src/pages/email.tsx b/frontend/src/pages/email.tsx new file mode 100644 index 0000000..220c4c5 --- /dev/null +++ b/frontend/src/pages/email.tsx @@ -0,0 +1,265 @@ +import {useState, useEffect, useCallback} from "react"; + +import { + getEmailConfigsApi, + createEmailConfigApi, + updateEmailConfigApi, + deleteEmailConfigApi, +} from "@/api/email"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; +import {Button} from "@/components/ui/button"; +import {Input} from "@/components/ui/input"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; + +import {Loader2, Plus, Pencil, Trash, RefreshCw} from "lucide-react"; + +export default function EmailPage() { + const [emails, setEmails] = useState([]); + const [loading, setLoading] = useState(true); + + const [openModal, setOpenModal] = useState(false); + const [editing, setEditing] = useState(null); + + const [searchText, setSearchText] = useState(""); + + const [form, setForm] = useState({ + name: "", + email: "", + type: "APPOINTMENT", + isActive: true, + }); + + const fetchAll = useCallback(async () => { + setLoading(true); + try { + const res = await getEmailConfigsApi(); + setEmails(res?.data || []); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchAll(); + }, [fetchAll]); + + const filtered = emails.filter( + (e) => + e.email.toLowerCase().includes(searchText.toLowerCase()) || + e.name.toLowerCase().includes(searchText.toLowerCase()), + ); + + function handleChange(e: any) { + setForm({...form, [e.target.name]: e.target.value}); + } + + function openAdd() { + setEditing(null); + setForm({ + name: "", + email: "", + type: "APPOINTMENT", + isActive: true, + }); + setOpenModal(true); + } + + function openEdit(item: any) { + setEditing(item); + setForm(item); + setOpenModal(true); + } + + async function handleSubmit() { + try { + if (editing) { + await updateEmailConfigApi(editing.id, form); + } else { + await createEmailConfigApi(form); + } + + setOpenModal(false); + fetchAll(); + } catch (err) { + console.error(err); + } + } + + async function handleDelete(id: number) { + if (!confirm("Delete email config?")) return; + await deleteEmailConfigApi(id); + fetchAll(); + } + + return ( +
+
+

Email Config

+ +
+ setSearchText(e.target.value)} + className="w-[200px]" + /> + + + + +
+
+ + + + Email List + + + + + + + ID + Name + Email + Type + Status + Actions + + + + + {loading ? ( + + + + + + ) : filtered.length === 0 ? ( + + + No data + + + ) : ( + filtered.map((item) => ( + + {item.id} + {item.name} + {item.email} + {item.type} + + {item.isActive ? "Active" : "Inactive"} + + + + + + + + + )) + )} + +
+
+
+ + + + + {editing ? "Edit Email" : "Add Email"} + + +
+ + + + + + + +
+ + + + + +
+
+
+ ); +} From 827764107751bb11cc582dd0d6df10e266439653 Mon Sep 17 00:00:00 2001 From: ARJUN S THAMPI <61703062+arjun-thampi@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:35:23 +0530 Subject: [PATCH 3/4] feat: career page added --- frontend/src/App.tsx | 6 +- frontend/src/api/career.ts | 11 + frontend/src/components/layout/Sidebar.tsx | 10 +- frontend/src/pages/Career.tsx | 287 +++++++++++++++++++++ package-lock.json | 6 + 5 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 frontend/src/api/career.ts create mode 100644 frontend/src/pages/Career.tsx create mode 100644 package-lock.json diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9a1e549..d8a1b4d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import {BrowserRouter, Routes, Route, Navigate} from "react-router-dom"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import Login from "@/pages/Login"; @@ -8,13 +8,14 @@ import DashboardLayout from "./layouts/DashboardLayout"; import ProtectedRoute from "./auth/ProtectedRoute"; import PublicRoute from "./auth/PublicRoute"; -import {AuthProvider} from "./context/AuthContext"; +import { AuthProvider } from "./context/AuthContext"; import Department from "./pages/Department"; import Doctor from "./pages/Doctor"; import Blog from "./pages/Blog"; import BlogEditorPage from "./pages/BlogEditor"; import Appointment from "./pages/Appointment"; import EmailPage from "./pages/email"; +import CareerPage from "./pages/Career"; export default function App() { return ( @@ -34,6 +35,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/career.ts b/frontend/src/api/career.ts new file mode 100644 index 0000000..0ded9f0 --- /dev/null +++ b/frontend/src/api/career.ts @@ -0,0 +1,11 @@ +import apiClient from "@/api/client"; + +export const getCareersApi = async () => { + const res = await apiClient.get("/careers/getAll"); + return res.data; +}; + +export const deleteCareerApi = async (id: number) => { + const res = await apiClient.delete(`/careers/${id}`); + return res.data; +}; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index a36c7df..a3e1acd 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ -import {Link, useLocation} from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; -import {Button} from "@/components/ui/button"; -import {Separator} from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; export default function Sidebar() { const location = useLocation(); @@ -19,6 +19,10 @@ export default function Sidebar() { name: "Appointments", path: "/appointment", }, + { + name: "Career", + path: "/career", + }, { name: "Email", path: "/email", diff --git a/frontend/src/pages/Career.tsx b/frontend/src/pages/Career.tsx new file mode 100644 index 0000000..5782bd8 --- /dev/null +++ b/frontend/src/pages/Career.tsx @@ -0,0 +1,287 @@ +import { useState, useEffect, useCallback } from "react"; + +import { getCareersApi, deleteCareerApi } from "@/api/career"; + +import apiClient from "@/api/client"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; + +import { Input } from "@/components/ui/input"; + +import { Loader2, Plus, Pencil, Trash, RefreshCw } from "lucide-react"; + +export default function CareerPage() { + const [careers, setCareers] = useState([]); + const [loading, setLoading] = useState(true); + + const [openModal, setOpenModal] = useState(false); + const [editing, setEditing] = useState(null); + + const [searchText, setSearchText] = useState(""); + + const [form, setForm] = useState({ + post: "", + designation: "", + qualification: "", + experienceNeed: "", + email: "", + number: "", + status: "new", + }); + + const fetchAll = useCallback(async () => { + setLoading(true); + try { + const res = await getCareersApi(); + setCareers(res?.data || []); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchAll(); + }, [fetchAll]); + + const filteredCareers = careers.filter((item) => + item.post?.toLowerCase().includes(searchText.toLowerCase()), + ); + + function handleChange(e: any) { + setForm({ ...form, [e.target.name]: e.target.value }); + } + + function openAdd() { + setEditing(null); + setForm({ + post: "", + designation: "", + qualification: "", + experienceNeed: "", + email: "", + number: "", + status: "new", + }); + setOpenModal(true); + } + + function openEdit(item: any) { + setEditing(item); + + setForm({ + post: item.post || "", + designation: item.designation || "", + qualification: item.qualification || "", + experienceNeed: item.experienceNeed || "", + email: item.email || "", + number: item.number || "", + status: item.status || "new", + }); + + setOpenModal(true); + } + + async function handleSubmit() { + try { + if (editing) { + await apiClient.patch(`/careers/${editing.id}`, form); + } else { + await apiClient.post("/careers", form); + } + + setOpenModal(false); + fetchAll(); + } catch (err) { + console.error(err); + } + } + + async function handleDelete(id: number) { + if (!confirm("Delete career?")) return; + await deleteCareerApi(id); + fetchAll(); + } + + return ( +
+
+

Careers

+ +
+ setSearchText(e.target.value)} + className="w-[220px]" + /> + + + + +
+
+ + + + Career List + + + +
+ + + + ID + Post + Designation + Qualification + Experience + Email + Phone + Status + Actions + + + + + {loading ? ( + + + + + + ) : filteredCareers.length === 0 ? ( + + + No careers found + + + ) : ( + filteredCareers.map((item) => ( + + {item.id} + {item.post} + {item.designation} + {item.qualification} + {item.experienceNeed} + {item.email} + {item.number} + {item.status} + + + + + + + + )) + )} + +
+
+
+
+ + {/* MODAL */} + + + + {editing ? "Edit Career" : "Add Career"} + + +
+ + + + + + + +
+ + + + + +
+
+
+ ); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7f73ca3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "gg-backend-new", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From de854ed538a2b304ea175a7c889e2219f7f1c028 Mon Sep 17 00:00:00 2001 From: ARJUN S THAMPI <61703062+arjun-thampi@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:10:15 +0530 Subject: [PATCH 4/4] feat:add candidate pages --- .../src/controllers/candidate.controller.js | 44 ++++- frontend/src/App.tsx | 2 + frontend/src/api/candidates.ts | 11 ++ frontend/src/components/layout/Sidebar.tsx | 7 +- frontend/src/pages/candidates.tsx | 187 ++++++++++++++++++ 5 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 frontend/src/api/candidates.ts create mode 100644 frontend/src/pages/candidates.tsx diff --git a/backend/src/controllers/candidate.controller.js b/backend/src/controllers/candidate.controller.js index bdc6b6c..b87f053 100644 --- a/backend/src/controllers/candidate.controller.js +++ b/backend/src/controllers/candidate.controller.js @@ -1,10 +1,14 @@ import prisma from "../prisma/client.js"; +import { sendEmail } from "../utils/sendEmail.js"; +import { getEmailsByType } from "../utils/getEmailByTypes.js"; + // CREATE CANDIDATE export const createCandidate = async (req, res) => { try { - const {fullName, mobile, email, subject, coverLetter, careerId} = req.body; + const { fullName, mobile, email, subject, coverLetter, careerId } = + req.body; if (!fullName || !mobile || !email || !careerId) { return res.status(400).json({ @@ -22,8 +26,38 @@ export const createCandidate = async (req, res) => { coverLetter, careerId: Number(careerId), }, + include: { + career: true, + }, }); + try { + const emailList = await getEmailsByType("CANDIDATE"); + + if (emailList && emailList.length > 0) { + await sendEmail({ + to: emailList, + subject: "New Job Application Received", + html: ` +

New Candidate Application

+ +

Name: ${fullName}

+

Phone: ${mobile}

+

Email: ${email}

+ +

Applied For: ${candidate.career?.post || "-"}

+

Designation: ${candidate.career?.designation || "-"}

+ +

Subject: ${subject || "-"}

+

Cover Letter:

+

${coverLetter || "-"}

+ `, + }); + } + } catch (err) { + console.error("Candidate email failed:", err); + } + res.status(201).json({ success: true, message: "Application submitted successfully", @@ -68,7 +102,7 @@ export const getCandidates = async (req, res) => { export const getCandidate = async (req, res) => { try { - const {id} = req.params; + const { id } = req.params; const candidate = await prisma.candidate.findUnique({ where: { @@ -103,7 +137,7 @@ export const getCandidate = async (req, res) => { export const getCandidatesByCareer = async (req, res) => { try { - const {careerId} = req.params; + const { careerId } = req.params; const candidates = await prisma.candidate.findMany({ where: { @@ -134,7 +168,7 @@ export const getCandidatesByCareer = async (req, res) => { export const updateCandidate = async (req, res) => { try { - const {id} = req.params; + const { id } = req.params; const candidate = await prisma.candidate.update({ where: { @@ -161,7 +195,7 @@ export const updateCandidate = async (req, res) => { export const deleteCandidate = async (req, res) => { try { - const {id} = req.params; + const { id } = req.params; await prisma.candidate.delete({ where: { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d8a1b4d..f2a027b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import BlogEditorPage from "./pages/BlogEditor"; import Appointment from "./pages/Appointment"; import EmailPage from "./pages/email"; import CareerPage from "./pages/Career"; +import CandidatePage from "./pages/candidates"; export default function App() { return ( @@ -36,6 +37,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/candidates.ts b/frontend/src/api/candidates.ts new file mode 100644 index 0000000..fa81dc6 --- /dev/null +++ b/frontend/src/api/candidates.ts @@ -0,0 +1,11 @@ +import apiClient from "@/api/client"; + +export const getCandidatesApi = async () => { + const res = await apiClient.get("/candidates/getAll"); + return res.data; +}; + +export const deleteCandidateApi = async (id: number) => { + const res = await apiClient.delete(`/candidates/${id}`); + return res.data; +}; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index a3e1acd..d434faa 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -23,6 +23,10 @@ export default function Sidebar() { name: "Career", path: "/career", }, + { + name: "Candidates", + path: "/candidate", + }, { name: "Email", path: "/email", @@ -49,8 +53,7 @@ export default function Sidebar() { diff --git a/frontend/src/pages/candidates.tsx b/frontend/src/pages/candidates.tsx new file mode 100644 index 0000000..45a8ea7 --- /dev/null +++ b/frontend/src/pages/candidates.tsx @@ -0,0 +1,187 @@ +import { useState, useEffect, useCallback } from "react"; + +import { getCandidatesApi, deleteCandidateApi } from "@/api/candidates"; +import { exportToExcel } from "@/utils/exportToExcel"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +import { Loader2, Trash, RefreshCw, Download } from "lucide-react"; + +export default function CandidatePage() { + const [candidates, setCandidates] = useState([]); + const [loading, setLoading] = useState(true); + + const [searchText, setSearchText] = useState(""); + const [filterCareer, setFilterCareer] = useState(""); + + const fetchAll = useCallback(async () => { + setLoading(true); + try { + const res = await getCandidatesApi(); + setCandidates(res?.data || []); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchAll(); + }, [fetchAll]); + + const filteredCandidates = candidates.filter((item) => { + const matchesSearch = + item.fullName?.toLowerCase().includes(searchText.toLowerCase()) || + item.mobile?.includes(searchText) || + item.email?.toLowerCase().includes(searchText.toLowerCase()); + + const matchesCareer = filterCareer + ? item.career?.post?.toLowerCase().includes(filterCareer.toLowerCase()) + : true; + + return matchesSearch && matchesCareer; + }); + + async function handleDelete(id: number) { + if (!confirm("Delete candidate?")) return; + await deleteCandidateApi(id); + fetchAll(); + } + + const handleExport = () => { + const exportData = filteredCandidates.map((item) => ({ + ID: item.id, + Name: item.fullName, + Phone: item.mobile, + Email: item.email, + Career: item.career?.post, + Designation: item.career?.designation, + Subject: item.subject, + CoverLetter: item.coverLetter, + Date: new Date(item.createdAt).toLocaleDateString(), + })); + + exportToExcel(exportData, "candidates"); + }; + + return ( +
+
+

Candidates

+ +
+ setSearchText(e.target.value)} + className="w-[220px]" + /> + + setFilterCareer(e.target.value)} + className="w-[200px]" + /> + + + + +
+
+ + + + Candidate List + + + +
+ + + + ID + Name + Phone + Email + Career + Designation + Subject + Cover Letter + Applied On + Actions + + + + + {loading ? ( + + + + + + ) : filteredCandidates.length === 0 ? ( + + + No candidates found + + + ) : ( + filteredCandidates.map((item) => ( + + {item.id} + {item.fullName} + {item.mobile} + {item.email} + + {item.career?.post} + {item.career?.designation} + + {item.subject} + + + {item.coverLetter} + + + + {new Date(item.createdAt).toLocaleDateString()} + + + + + + + )) + )} + +
+
+
+
+
+ ); +}