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