2 Commits

Author SHA1 Message Date
ARJUN S THAMPI
6e999c36c5 feat: add email page 2026-03-19 16:41:46 +05:30
ARJUN S THAMPI
834eaad3c3 feat:add email send functionality 2026-03-19 13:12:04 +05:30
19 changed files with 1066 additions and 6 deletions

View File

@@ -21,6 +21,7 @@
"express-session": "^1.19.0", "express-session": "^1.19.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^2.1.1", "multer": "^2.1.1",
"postmark": "^4.0.7",
"prisma": "^6.19.2" "prisma": "^6.19.2"
}, },
"devDependencies": { "devDependencies": {
@@ -211,6 +212,23 @@
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT" "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": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -482,6 +500,18 @@
"integrity": "sha512-IuA8LeyLU5p1B+HyhOsqR6oxyFQ11k3i9e9aXw40CrHFTRO2Y1npNBVU3W1SvhKAbUU7R/YikUBdcYFP0RcJag==", "integrity": "sha512-IuA8LeyLU5p1B+HyhOsqR6oxyFQ11k3i9e9aXw40CrHFTRO2Y1npNBVU3W1SvhKAbUU7R/YikUBdcYFP0RcJag==",
"license": "MIT" "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": { "node_modules/concat-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
@@ -601,6 +631,15 @@
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"license": "MIT" "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -715,6 +754,21 @@
"node": ">= 0.4" "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": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -879,6 +933,63 @@
"url": "https://opencollective.com/express" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1022,6 +1133,21 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -1294,9 +1420,9 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.2", "version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
@@ -1586,6 +1712,15 @@
"pathe": "^2.0.3" "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": { "node_modules/prisma": {
"version": "6.19.2", "version": "6.19.2",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
@@ -1625,6 +1760,12 @@
"node": ">= 0.10" "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": { "node_modules/pstree.remy": {
"version": "1.1.8", "version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",

View File

@@ -27,6 +27,7 @@
"express-session": "^1.19.0", "express-session": "^1.19.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^2.1.1", "multer": "^2.1.1",
"postmark": "^4.0.7",
"prisma": "^6.19.2" "prisma": "^6.19.2"
}, },
"devDependencies": { "devDependencies": {

View File

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

View File

@@ -173,4 +173,16 @@ model AcademicsResearch {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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
} }

View File

@@ -12,6 +12,7 @@ import candidateRoutes from "./routes/candidate.routes.js";
import appointmentRoutes from "./routes/appointment.routes.js"; import appointmentRoutes from "./routes/appointment.routes.js";
import inquiryRoutes from "./routes/inquiry.routes.js"; import inquiryRoutes from "./routes/inquiry.routes.js";
import academicsResearchRoutes from "./routes/academicsResearch.routes.js"; import academicsResearchRoutes from "./routes/academicsResearch.routes.js";
import emailConfigRoutes from "./routes/emailConfig.routes.js";
dotenv.config(); dotenv.config();
@@ -47,6 +48,7 @@ app.use("/api/candidates", candidateRoutes);
app.use("/api/appointments", appointmentRoutes); app.use("/api/appointments", appointmentRoutes);
app.use("/api/inquiry", inquiryRoutes); app.use("/api/inquiry", inquiryRoutes);
app.use("/api/academics", academicsResearchRoutes); app.use("/api/academics", academicsResearchRoutes);
app.use("/api/email", emailConfigRoutes);
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
app.listen(PORT, () => { app.listen(PORT, () => {

View File

@@ -1,5 +1,6 @@
import prisma from "../prisma/client.js"; 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) => { export const createAppointment = async (req, res) => {
try { 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: `
<h2>New Appointment Booked</h2>
<p><b>Name:</b> ${name}</p>
<p><b>Phone:</b> ${mobileNumber}</p>
<p><b>Email:</b> ${email || "-"}</p>
<p><b>Doctor:</b> ${appointment.doctor?.name}</p>
<p><b>Department:</b> ${appointment.department?.name}</p>
<p><b>Date:</b> ${new Date(date).toLocaleDateString()}</p>
<p><b>Message:</b> ${message || "-"}</p>
`,
});
}
} catch (err) {
console.error("Email failed:", err);
}
res.status(201).json({ res.status(201).json({
success: true, success: true,
message: "Appointment booked successfully", message: "Appointment booked successfully",

View File

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

View File

@@ -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.patch("/:id", jwtAuthMiddleware, updateEmailConfig);
router.delete("/:id", jwtAuthMiddleware, deleteEmailConfig);
export default router;

View File

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

View File

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

View File

@@ -23,6 +23,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"file-saver": "^2.0.5",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
@@ -32,7 +33,8 @@
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
@@ -4484,6 +4486,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "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": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -4814,6 +4825,19 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -4982,6 +5006,15 @@
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
"license": "MIT" "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": { "node_modules/codex-notifier": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/codex-notifier/-/codex-notifier-1.1.2.tgz", "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5964,6 +6009,12 @@
"node": ">=16.0.0" "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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -6113,6 +6164,15 @@
"node": ">= 0.6" "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": { "node_modules/fresh": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -8684,6 +8744,18 @@
"node": ">=0.10.0" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -9336,6 +9408,24 @@
"node": ">= 8" "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": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -9423,6 +9513,27 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -25,6 +25,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"file-saver": "^2.0.5",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
@@ -34,7 +35,8 @@
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

View File

@@ -13,6 +13,8 @@ import Department from "./pages/Department";
import Doctor from "./pages/Doctor"; import Doctor from "./pages/Doctor";
import Blog from "./pages/Blog"; import Blog from "./pages/Blog";
import BlogEditorPage from "./pages/BlogEditor"; import BlogEditorPage from "./pages/BlogEditor";
import Appointment from "./pages/Appointment";
import EmailPage from "./pages/email";
export default function App() { export default function App() {
return ( return (
@@ -30,6 +32,8 @@ export default function App() {
<Route path="/blog" element={<Blog />} /> <Route path="/blog" element={<Blog />} />
<Route path="/blog/create" element={<BlogEditorPage />} /> <Route path="/blog/create" element={<BlogEditorPage />} />
<Route path="/blog/edit/:id" element={<BlogEditorPage />} /> <Route path="/blog/edit/:id" element={<BlogEditorPage />} />
<Route path="/appointment" element={<Appointment />} />
<Route path="/email" element={<EmailPage />} />
</Route> </Route>
</Route> </Route>

View File

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

36
frontend/src/api/email.ts Normal file
View File

@@ -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<EmailConfig>,
) => {
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;
};

View File

@@ -15,6 +15,14 @@ export default function Sidebar() {
name: "Doctor", name: "Doctor",
path: "/doctor", path: "/doctor",
}, },
{
name: "Appointments",
path: "/appointment",
},
{
name: "Email",
path: "/email",
},
{ {
name: "Blog", name: "Blog",
path: "/blog", path: "/blog",

View File

@@ -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<any[]>([]);
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 (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold">Appointments</h1>
<div className="flex flex-wrap gap-2">
<Input
placeholder="Search name / phone / email..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[220px]"
/>
<Input
placeholder="Filter Doctor"
value={filterDoctor}
onChange={(e) => setFilterDoctor(e.target.value)}
className="w-[180px]"
/>
<Input
placeholder="Filter Department"
value={filterDepartment}
onChange={(e) => setFilterDepartment(e.target.value)}
className="w-[200px]"
/>
<Input
type="date"
value={filterDate}
onChange={(e) => setFilterDate(e.target.value)}
className="w-[180px]"
/>
<Button variant="outline" onClick={fetchAll} disabled={loading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
<Button variant="outline" onClick={handleExport}>
<Download className="mr-2 h-4 w-4" />
Export
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Appointment List</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table className="min-w-[700px]">
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Email</TableHead>
<TableHead>Doctor</TableHead>
<TableHead>Department</TableHead>
<TableHead>Appointment Date</TableHead>
<TableHead>Message</TableHead>
<TableHead>Generated on</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : filteredAppointments.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center">
No appointments found
</TableCell>
</TableRow>
) : (
filteredAppointments.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>{item.mobileNumber}</TableCell>
<TableCell>{item.email}</TableCell>
<TableCell>{item.doctor?.name}</TableCell>
<TableCell>{item.department?.name}</TableCell>
{/* ✅ DATE ONLY */}
<TableCell>
{new Date(item.date).toLocaleDateString()}
</TableCell>
<TableCell className="max-w-[250px] whitespace-normal">
{item.message}
</TableCell>
<TableCell>
{" "}
{new Date(item.createdAt).toLocaleDateString()}
</TableCell>
<TableCell>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(item.id)}
>
<Trash className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<any[]>([]);
const [loading, setLoading] = useState(true);
const [openModal, setOpenModal] = useState(false);
const [editing, setEditing] = useState<any>(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 (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center flex-wrap gap-3">
<h1 className="text-2xl font-bold">Email Config</h1>
<div className="flex gap-2 flex-wrap">
<Input
placeholder="Search..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[200px]"
/>
<Button variant="outline" onClick={fetchAll}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
<Button onClick={openAdd}>
<Plus className="mr-2 h-4 w-4" />
Add Email
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Email List</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center">
<Loader2 className="animate-spin mx-auto" />
</TableCell>
</TableRow>
) : filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center">
No data
</TableCell>
</TableRow>
) : (
filtered.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>{item.email}</TableCell>
<TableCell>{item.type}</TableCell>
<TableCell>
{item.isActive ? "Active" : "Inactive"}
</TableCell>
<TableCell className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => openEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(item.id)}
>
<Trash className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? "Edit Email" : "Add Email"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Input
name="name"
placeholder="Name"
value={form.name}
onChange={handleChange}
/>
<Input
name="email"
placeholder="Email"
value={form.email}
onChange={handleChange}
/>
<select
name="type"
value={form.type}
onChange={handleChange}
className="border rounded px-2 py-2 w-full"
>
<option value="APPOINTMENT">APPOINTMENT</option>
<option value="CANDIDATE">CANDIDATE</option>
<option value="INQUIRY">INQUIRY</option>
</select>
<select
name="isActive"
value={form.isActive ? "true" : "false"}
onChange={(e) =>
setForm({
...form,
isActive: e.target.value === "true",
})
}
className="border rounded px-2 py-2 w-full"
>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpenModal(false)}>
Cancel
</Button>
<Button onClick={handleSubmit}>
{editing ? "Update" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

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