Compare commits

..

46 Commits

Author SHA1 Message Date
Kailasdevdas 16cf582e2c refactor: move Bytescale upload logic to backend for security 2026-04-16 15:33:22 +05:30
Kailasdevdas 5b4aacda04 Merge branch 'feat/bytescale-integration' into dev 2026-04-16 14:25:56 +05:30
Kailasdevdas dc3228a07a feat: email on inquiry 2026-04-16 14:03:50 +05:30
kailasdevdas fd60419c26 Merge pull request 'feat/blog' (#8) from feat/blog into dev
Reviewed-on: #8
2026-04-16 08:31:38 +00:00
Kailasdevdas c21ab02c2a feat: add doctor image in the response 2026-04-16 11:17:42 +05:30
Kailasdevdas c282b1825e feat: add Bytescale image uploads 2026-04-14 17:33:21 +05:30
rishalkv e74a5b09c2 feat/add blog 2026-04-14 16:04:44 +05:30
rishalkv 86afb86d3c feat/blig edit 2026-04-14 15:25:20 +05:30
Kailasdevdas 0fddd7a656 fix: add missing import 2026-04-14 12:56:47 +05:30
kailasdevdas de53008e2d Merge pull request 'fix/frontend-ui' (#6) from fix/frontend-ui into dev
Reviewed-on: #6
2026-04-14 07:19:58 +00:00
kailasdevdas 0f6b34487e Merge pull request 'fix/backend' (#7) from fix/backend into dev
Reviewed-on: #7
2026-04-14 07:19:45 +00:00
Kailasdevdas fb298cb846 fix: add JWT middleware to private API routes 2026-04-08 16:44:41 +05:30
Kailasdevdas 9c44c66b22 feat: get doctors by department 2026-04-08 16:40:42 +05:30
Kailasdevdas 29d2ed6b96 feat: get department by name 2026-04-08 16:39:29 +05:30
Kailasdevdas c4ebd19c15 fix: maintain same ui across all the pages 2026-04-08 16:30:50 +05:30
Kailasdevdas 1d55cfc4b8 fix: doctors page ui 2026-04-06 17:46:31 +05:30
arjunsthampi 661bf7a77f Merge pull request 'fix-department-ui' (#5) from fix-department-ui into dev
Reviewed-on: #5
2026-03-26 12:33:03 +00:00
ARJUN S THAMPI 7bce00800b chore : remove comment 2026-03-26 18:01:57 +05:30
ARJUN S THAMPI 427775a038 fix: refresh button ui 2026-03-26 17:58:49 +05:30
ARJUN S THAMPI 8004a7a21c fix: ui department 2026-03-26 14:38:23 +05:30
arjunsthampi 57f95661cc Merge pull request 'feat/news-&-media' (#4) from feat/news-&-media into dev
Reviewed-on: #4
2026-03-26 06:08:25 +00:00
ARJUN S THAMPI 9d149e6abe feat: pagination in newMedia 2026-03-26 11:30:26 +05:30
ARJUN S THAMPI 2ed1bee149 feat: add page newMedia 2026-03-26 11:20:03 +05:30
ARJUN S THAMPI 24a8bc4113 feat: add news media api 2026-03-25 17:59:36 +05:30
arjunsthampi f35eab14e6 Merge pull request 'feat/inquiry' (#3) from feat/inquiry into dev
Reviewed-on: #3
2026-03-25 09:16:55 +00:00
ARJUN S THAMPI 380cb4d999 feat : add page academics & research 2026-03-25 12:48:01 +05:30
ARJUN S THAMPI e546519e7a feat: add inquiry page 2026-03-25 11:26:45 +05:30
arjunsthampi b9f372145b Merge pull request 'feat/appointment-page' (#2) from feat/appointment-page into dev
Reviewed-on: #2
2026-03-25 04:43:15 +00:00
ARJUN S THAMPI de854ed538 feat:add candidate pages 2026-03-25 10:10:15 +05:30
ARJUN S THAMPI 8277641077 feat: career page added 2026-03-24 14:35:23 +05:30
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
ARJUN S THAMPI 1bbf7f9c1c feat: add blog page 2026-03-18 14:25:08 +05:30
ARJUN S THAMPI 2584539fb0 feat: add search in department 2026-03-17 17:28:18 +05:30
ARJUN S THAMPI 101c235855 feat: doctor search filter functionality added 2026-03-17 17:08:05 +05:30
ARJUN S THAMPI b89b2b1ba5 feat: department wise timing 2026-03-17 16:22:37 +05:30
ARJUN S THAMPI c11a3f9a7d fix: apis 2026-03-17 13:56:46 +05:30
ARJUN S THAMPI 763b887d65 feat:add doctor page 2026-03-17 13:11:00 +05:30
ARJUN S THAMPI db8cee836a feat: change dept name 2026-03-16 17:55:55 +05:30
ARJUN S THAMPI 46bbd8106b feat: add department dashboard 2026-03-16 17:55:33 +05:30
ARJUN S THAMPI aaa62ae3f5 feat : add academics api 2026-03-16 12:39:41 +05:30
ARJUN S THAMPI 9ae190754a feat : add appointment apis 2026-03-16 10:16:27 +05:30
ARJUN S THAMPI 9faa512c0b feat:add candidate apis 2026-03-13 16:26:06 +05:30
ARJUN S THAMPI 7955465be4 feat : add doctor & career api 2026-03-13 14:54:47 +05:30
arjunsthampi 3ac50d4132 Merge pull request 'feat : add front-end using shad cn' (#1) from feat/-addshadcn- into dev
Reviewed-on: #1
2026-03-13 04:34:14 +00:00
ARJUN S THAMPI 1206e51f6d feat :add package.json 2026-03-12 17:43:38 +05:30
97 changed files with 20469 additions and 9604 deletions
+2325
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -15,6 +15,7 @@
"license": "ISC",
"type": "module",
"dependencies": {
"@bytescale/sdk": "^3.53.0",
"@editorjs/editorjs": "^2.31.4",
"@editorjs/header": "^2.8.8",
"@editorjs/list": "^2.0.9",
@@ -27,7 +28,10 @@
"express-session": "^1.19.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
"prisma": "^6.19.2"
"node-fetch": "^3.3.2",
"postmark": "^4.0.7",
"prisma": "^6.19.2",
"slugify": "^1.6.9"
},
"devDependencies": {
"nodemon": "^3.1.11"
@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "Career" (
"id" SERIAL NOT NULL,
"post" TEXT NOT NULL,
"designation" TEXT,
"qualification" TEXT,
"experienceNeed" TEXT,
"email" TEXT,
"number" TEXT,
"status" TEXT NOT NULL DEFAULT 'new',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Career_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "Candidate" (
"id" SERIAL NOT NULL,
"fullName" TEXT NOT NULL,
"mobile" TEXT NOT NULL,
"email" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"coverLetter" TEXT NOT NULL,
"careerId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Candidate_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Candidate" ADD CONSTRAINT "Candidate_careerId_fkey" FOREIGN KEY ("careerId") REFERENCES "Career"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "Appointment" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"mobileNumber" TEXT NOT NULL,
"email" TEXT,
"message" TEXT,
"date" TIMESTAMP(3) NOT NULL,
"doctorId" INTEGER NOT NULL,
"departmentId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Appointment_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "Doctor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,15 @@
-- DropForeignKey
ALTER TABLE "Appointment" DROP CONSTRAINT "Appointment_departmentId_fkey";
-- DropForeignKey
ALTER TABLE "Appointment" DROP CONSTRAINT "Appointment_doctorId_fkey";
-- AlterTable
ALTER TABLE "Appointment" ALTER COLUMN "doctorId" SET DATA TYPE TEXT,
ALTER COLUMN "departmentId" SET DATA TYPE TEXT;
-- AddForeignKey
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "Doctor"("doctorId") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("departmentId") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "Inquiry" (
"id" SERIAL NOT NULL,
"fullName" TEXT NOT NULL,
"number" TEXT NOT NULL,
"emailId" TEXT,
"subject" TEXT,
"message" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Inquiry_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "AcademicsResearch" (
"id" SERIAL NOT NULL,
"fullName" TEXT NOT NULL,
"number" TEXT NOT NULL,
"emailId" TEXT,
"subject" TEXT,
"courseName" TEXT,
"message" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AcademicsResearch_pkey" PRIMARY KEY ("id")
);
@@ -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")
);
@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "NewsMedia" (
"id" SERIAL NOT NULL,
"headline" TEXT NOT NULL,
"content" TEXT,
"firstPara" TEXT,
"secondPara" TEXT,
"author" TEXT,
"date" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NewsMedia_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "image" TEXT;
@@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `slug` to the `Blog` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Blog" ADD COLUMN "slug" TEXT NOT NULL;
@@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[slug]` on the table `Blog` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Blog_slug_key" ON "Blog"("slug");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Department" ADD COLUMN "image" TEXT;
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "NewsImage" (
"id" SERIAL NOT NULL,
"url" TEXT NOT NULL,
"newsMediaId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NewsImage_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "NewsImage" ADD CONSTRAINT "NewsImage_newsMediaId_fkey" FOREIGN KEY ("newsMediaId") REFERENCES "NewsMedia"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+171 -55
View File
@@ -1,4 +1,3 @@
generator client {
provider = "prisma-client-js"
}
@@ -9,92 +8,209 @@ datasource db {
}
model User {
id Int @id @default(autoincrement())
username String @unique
password String
role String? @default("admin")
id Int @id @default(autoincrement())
username String @unique
password String
role String? @default("admin")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Doctor {
id Int @id @default(autoincrement())
doctorId String @unique
id Int @id @default(autoincrement())
doctorId String @unique
name String
image String?
designation String?
workingStatus String?
qualification String?
departments DoctorDepartment[]
departments DoctorDepartment[]
appointments Appointment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Department {
id Int @id @default(autoincrement())
departmentId String @unique
name String
id Int @id @default(autoincrement())
departmentId String @unique
name String
image String?
para1 String?
para2 String?
para3 String?
facilities String?
services String?
doctors DoctorDepartment[]
para1 String?
para2 String?
para3 String?
facilities String?
services String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
doctors DoctorDepartment[]
appointments Appointment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model DoctorDepartment {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
doctorId Int
departmentId Int
doctorId Int
departmentId Int
doctor Doctor @relation(fields: [doctorId], references: [id])
department Department @relation(fields: [departmentId], references: [id])
doctor Doctor @relation(fields: [doctorId], references: [id])
department Department @relation(fields: [departmentId], references: [id])
timing DoctorTiming?
timing DoctorTiming?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([doctorId, departmentId])
}
model DoctorTiming {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
doctorDepartmentId Int @unique
doctorDepartment DoctorDepartment @relation(fields: [doctorDepartmentId], references: [id])
doctorDepartmentId Int @unique
doctorDepartment DoctorDepartment @relation(fields: [doctorDepartmentId], references: [id])
monday String?
tuesday String?
wednesday String?
thursday String?
friday String?
saturday String?
sunday String?
additional String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Blog {
id Int @id @default(autoincrement())
title String
writer String?
image String?
content Json
isActive Boolean @default(true)
monday String?
tuesday String?
wednesday String?
thursday String?
friday String?
saturday String?
sunday String?
additional String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
}
model Blog {
id Int @id @default(autoincrement())
title String
writer String?
image String?
content Json
isActive Boolean @default(true)
slug String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Career {
id Int @id @default(autoincrement())
post String
designation String?
qualification String?
experienceNeed String?
email String?
number String?
status String @default("new")
candidates Candidate[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Candidate {
id Int @id @default(autoincrement())
fullName String
mobile String
email String
subject String
coverLetter String
careerId Int
career Career @relation(fields: [careerId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Appointment {
id Int @id @default(autoincrement())
name String
mobileNumber String
email String?
message String?
date DateTime
doctorId String
departmentId String
doctor Doctor @relation(fields: [doctorId], references: [doctorId])
department Department @relation(fields: [departmentId], references: [departmentId])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Inquiry {
id Int @id @default(autoincrement())
fullName String
number String
emailId String?
subject String?
message String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AcademicsResearch {
id Int @id @default(autoincrement())
fullName String
number String
emailId String?
subject String?
courseName String?
message String?
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
}
model NewsMedia {
id Int @id @default(autoincrement())
headline String
content String?
firstPara String?
secondPara String?
author String?
date DateTime?
images NewsImage[]
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model NewsImage {
id Int @id @default(autoincrement())
url String
newsMediaId Int
newsMedia NewsMedia @relation(fields: [newsMediaId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
}
+16
View File
@@ -6,6 +6,14 @@ import departmentRoutes from "./routes/department.routes.js";
import authRoutes from "./routes/auth.routes.js";
import blogRoutes from "./routes/blog.routes.js";
import uploadRoutes from "./routes/upload.routes.js";
import doctorRoutes from "./routes/doctor.routes.js";
import careerRoutes from "./routes/career.routes.js";
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";
import newsMediaRoutes from "./routes/newsMedia.routes.js";
dotenv.config();
@@ -35,6 +43,14 @@ app.use("/api/auth", authRoutes);
app.use("/api/blogs", blogRoutes);
app.use("/uploads", express.static("uploads"));
app.use("/api/upload", uploadRoutes);
app.use("/api/doctors", doctorRoutes);
app.use("/api/careers", careerRoutes);
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);
app.use("/api/newsMedia", newsMediaRoutes);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
@@ -0,0 +1,146 @@
import prisma from "../prisma/client.js";
import { sendEmail } from "../utils/sendEmail.js";
import { getEmailsByType } from "../utils/getEmailByTypes.js";
// CREATE ACADEMICS & RESEARCH
export const createAcademicsResearch = async (req, res) => {
try {
const { fullName, number, emailId, subject, courseName, message } =
req.body;
if (!fullName || !number) {
return res.status(400).json({
success: false,
message: "Full name and number are required",
});
}
const data = await prisma.academicsResearch.create({
data: {
fullName,
number,
emailId,
subject,
courseName,
message,
},
});
try {
const emailList = await getEmailsByType("ACADEMICS");
if (emailList && emailList.length > 0) {
await sendEmail({
to: emailList,
subject: "New Academics & Research Inquiry",
html: `
<h2>New Academics & Research Inquiry</h2>
<p><b>Name:</b> ${fullName}</p>
<p><b>Phone:</b> ${number}</p>
<p><b>Email:</b> ${emailId || "-"}</p>
<p><b>Course:</b> ${courseName || "-"}</p>
<p><b>Subject:</b> ${subject || "-"}</p>
<p><b>Message:</b></p>
<p>${message || "-"}</p>
`,
});
}
} catch (err) {
console.error("Academics email failed:", err);
}
res.status(200).json({
success: true,
status: 200,
data,
message: "Academics & Research added successfully",
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to add Academics & Research inquiry",
});
}
};
// GET ALL
export const getAcademicsResearch = async (req, res) => {
try {
const data = await prisma.academicsResearch.findMany({
orderBy: {
createdAt: "desc",
},
});
res.json({
success: true,
data,
});
} catch (error) {
res.status(500).json({
success: false,
message: "Failed to fetch records",
});
}
};
// GET SINGLE
export const getSingleAcademicsResearch = async (req, res) => {
try {
const { id } = req.params;
const data = await prisma.academicsResearch.findUnique({
where: {
id: Number(id),
},
});
if (!data) {
return res.status(404).json({
success: false,
message: "Record not found",
});
}
res.json({
success: true,
data,
});
} catch (error) {
res.status(500).json({
success: false,
message: "Failed to fetch record",
});
}
};
// DELETE
export const deleteAcademicsResearch = async (req, res) => {
try {
const { id } = req.params;
await prisma.academicsResearch.delete({
where: {
id: Number(id),
},
});
res.json({
success: true,
message: "Record deleted successfully",
});
} catch (error) {
res.status(500).json({
success: false,
message: "Failed to delete record",
});
}
};
@@ -0,0 +1,248 @@
import prisma from "../prisma/client.js";
import {sendEmail} from "../utils/sendEmail.js";
import {getEmailsByType} from "../utils/getEmailByTypes.js";
export const createAppointment = async (req, res) => {
try {
const {name, mobileNumber, email, message, date, doctorId, departmentId} =
req.body;
if (!name || !mobileNumber || !doctorId || !departmentId || !date) {
return res.status(400).json({
success: false,
message: "Required fields missing",
});
}
const appointment = await prisma.appointment.create({
data: {
name,
mobileNumber,
email,
message,
date: new Date(date),
doctorId,
departmentId,
},
include: {
doctor: true,
department: true,
},
});
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({
success: true,
message: "Appointment booked successfully",
data: appointment,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to create appointment",
});
}
};
// GET ALL APPOINTMENTS
export const getAppointments = async (req, res) => {
try {
const appointments = await prisma.appointment.findMany({
include: {
doctor: true,
department: true,
},
orderBy: {
createdAt: "desc",
},
});
res.status(200).json({
success: true,
data: appointments,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to fetch appointments",
});
}
};
// GET SINGLE APPOINTMENT
export const getAppointment = async (req, res) => {
try {
const {id} = req.params;
const appointment = await prisma.appointment.findUnique({
where: {
id: Number(id),
},
include: {
doctor: true,
department: true,
},
});
if (!appointment) {
return res.status(404).json({
success: false,
message: "Appointment not found",
});
}
res.status(200).json({
success: true,
data: appointment,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to fetch appointment",
});
}
};
// GET APPOINTMENTS BY DOCTOR
export const getAppointmentsByDoctor = async (req, res) => {
try {
const {doctorId} = req.params;
const appointments = await prisma.appointment.findMany({
where: {
doctorId,
},
include: {
doctor: true,
department: true,
},
orderBy: {
date: "asc",
},
});
res.status(200).json({
success: true,
data: appointments,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to fetch doctor appointments",
});
}
};
// GET APPOINTMENTS BY DEPARTMENT
export const getAppointmentsByDepartment = async (req, res) => {
try {
const {departmentId} = req.params;
const appointments = await prisma.appointment.findMany({
where: {
departmentId,
},
include: {
doctor: true,
department: true,
},
});
res.status(200).json({
success: true,
data: appointments,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to fetch department appointments",
});
}
};
// UPDATE APPOINTMENT
export const updateAppointment = async (req, res) => {
try {
const {id} = req.params;
const appointment = await prisma.appointment.update({
where: {
id: Number(id),
},
data: req.body,
include: {
doctor: true,
department: true,
},
});
res.status(200).json({
success: true,
message: "Appointment updated successfully",
data: appointment,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to update appointment",
});
}
};
//DELETE APPOINTMENT
export const deleteAppointment = async (req, res) => {
try {
const {id} = req.params;
await prisma.appointment.delete({
where: {
id: Number(id),
},
});
res.status(200).json({
success: true,
message: "Appointment deleted successfully",
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to delete appointment",
});
}
};
@@ -1,4 +1,5 @@
import prisma from "../prisma/client.js";
import slugify from "slugify";
/* CREATE BLOG */
@@ -13,6 +14,7 @@ export async function createBlog(req, res) {
image,
content,
isActive,
slug: slugify(title),
},
});
@@ -54,6 +56,26 @@ export async function getAllBlogs(req, res) {
/* GET SINGLE BLOG */
export async function getBlog(req, res) {
try {
const slug = req.params.slug;
const blog = await prisma.blog.findUnique({
where: {slug},
});
if (!blog) {
return res.status(404).json({error: "Blog not found"});
}
res.json(blog);
} catch (error) {
res.status(500).json({error: error.message});
}
}
/* GET SINGLE BLOG (ADMIN)*/
export async function getBlogForAdmin(req, res) {
try {
const id = Number(req.params.id);
@@ -0,0 +1,217 @@
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;
if (!fullName || !mobile || !email || !careerId) {
return res.status(400).json({
success: false,
message: "Required fields missing",
});
}
const candidate = await prisma.candidate.create({
data: {
fullName,
mobile,
email,
subject,
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: `
<h2>New Candidate Application</h2>
<p><b>Name:</b> ${fullName}</p>
<p><b>Phone:</b> ${mobile}</p>
<p><b>Email:</b> ${email}</p>
<p><b>Applied For:</b> ${candidate.career?.post || "-"}</p>
<p><b>Designation:</b> ${candidate.career?.designation || "-"}</p>
<p><b>Subject:</b> ${subject || "-"}</p>
<p><b>Cover Letter:</b></p>
<p>${coverLetter || "-"}</p>
`,
});
}
} catch (err) {
console.error("Candidate email failed:", err);
}
res.status(201).json({
success: true,
message: "Application submitted successfully",
data: candidate,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to create candidate",
});
}
};
// GET ALL CANDIDATES
export const getCandidates = async (req, res) => {
try {
const candidates = await prisma.candidate.findMany({
include: {
career: true,
},
orderBy: {
createdAt: "desc",
},
});
res.status(200).json({
success: true,
data: candidates,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to fetch candidates",
});
}
};
// GET SINGLE CANDIDATE
export const getCandidate = async (req, res) => {
try {
const { id } = req.params;
const candidate = await prisma.candidate.findUnique({
where: {
id: Number(id),
},
include: {
career: true,
},
});
if (!candidate) {
return res.status(404).json({
success: false,
message: "Candidate not found",
});
}
res.status(200).json({
success: true,
data: candidate,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to fetch candidate",
});
}
};
// GET CANDIDATES BY CAREER
export const getCandidatesByCareer = async (req, res) => {
try {
const { careerId } = req.params;
const candidates = await prisma.candidate.findMany({
where: {
careerId: Number(careerId),
},
include: {
career: true,
},
orderBy: {
createdAt: "desc",
},
});
res.status(200).json({
success: true,
data: candidates,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to fetch candidates",
});
}
};
// UPDATE CANDIDATE
export const updateCandidate = async (req, res) => {
try {
const { id } = req.params;
const candidate = await prisma.candidate.update({
where: {
id: Number(id),
},
data: req.body,
});
res.status(200).json({
success: true,
message: "Candidate updated successfully",
data: candidate,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to update candidate",
});
}
};
// DELETE CANDIDATE
export const deleteCandidate = async (req, res) => {
try {
const { id } = req.params;
await prisma.candidate.delete({
where: {
id: Number(id),
},
});
res.status(200).json({
success: true,
message: "Candidate deleted successfully",
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to delete candidate",
});
}
};
@@ -0,0 +1,128 @@
import prisma from "../prisma/client.js";
// GET ALL CAREERS
export const getAllCareers = async (req, res) => {
try {
const careers = await prisma.career.findMany({
orderBy: {createdAt: "desc"},
});
const response = careers.map((c) => ({
id: c.id,
post: c.post,
designation: c.designation,
qualification: c.qualification,
experienceNeed: c.experienceNeed,
email: c.email,
number: c.number,
status: c.status,
}));
return res.status(200).json({
success: true,
data: response,
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to fetch careers",
});
}
};
// CREATE CAREER
export const createCareer = async (req, res) => {
try {
const {
post,
designation,
qualification,
experienceNeed,
email,
number,
status,
} = req.body;
if (!post || !designation) {
return res.status(400).json({
success: false,
message: "Post and designation are required",
});
}
const career = await prisma.career.create({
data: {
post,
designation,
qualification,
experienceNeed,
email,
number,
status,
},
});
return res.status(201).json({
success: true,
message: "Career created successfully",
data: career,
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to create career",
});
}
};
// UPDATE CAREER (PATCH)
export const updateCareer = async (req, res) => {
try {
const {id} = req.params;
const career = await prisma.career.update({
where: {id: Number(id)},
data: req.body,
});
return res.status(200).json({
success: true,
message: "Career updated successfully",
data: career,
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to update career",
});
}
};
// DELETE CAREER
export const deleteCareer = async (req, res) => {
try {
const {id} = req.params;
await prisma.career.delete({
where: {id: Number(id)},
});
return res.status(200).json({
success: true,
message: "Career deleted successfully",
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to delete career",
});
}
};
@@ -8,7 +8,8 @@ export const getAllDepartments = async (req, res) => {
const response = departments.map((dep) => ({
departmentId: dep.departmentId,
Department: dep.name,
name: dep.name,
image: dep.image ?? "",
para1: dep.para1 ?? "",
para2: dep.para2 ?? "",
para3: dep.para3 ?? "",
@@ -29,10 +30,66 @@ export const getAllDepartments = async (req, res) => {
}
};
export const getDepartmentByName = async (req, res) => {
try {
const {name} = req.query;
if (!name) {
return res.status(400).json({
success: false,
message: "Department name is required",
});
}
const department = await prisma.department.findFirst({
where: {
name: name,
},
});
if (!department) {
return res.status(404).json({
success: false,
message: "Department not found",
});
}
const response = {
departmentId: department.departmentId,
name: department.name,
image: department.image ?? "",
para1: department.para1 ?? "",
para2: department.para2 ?? "",
para3: department.para3 ?? "",
facilities: department.facilities ?? "",
services: department.services ?? "",
};
return res.status(200).json({
success: true,
data: [response],
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to fetch department",
});
}
};
export async function createDepartment(req, res) {
try {
const {departmentId, name, para1, para2, para3, facilities, services} =
req.body;
const {
departmentId,
name,
image,
para1,
para2,
para3,
facilities,
services,
} = req.body;
if (!departmentId || !name) {
return res
@@ -44,6 +101,7 @@ export async function createDepartment(req, res) {
data: {
departmentId,
name,
image,
para1,
para2,
para3,
@@ -64,3 +122,57 @@ export async function createDepartment(req, res) {
res.status(500).json({error: "Failed to create department"});
}
}
export const updateDepartment = async (req, res) => {
try {
const {departmentId} = req.params;
const {name, image, para1, para2, para3, facilities, services} = req.body;
const department = await prisma.department.update({
where: {departmentId},
data: {
name,
image,
para1,
para2,
para3,
facilities,
services,
},
});
return res.status(200).json({
success: true,
message: "Department updated successfully",
data: department,
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to update department",
});
}
};
export const deleteDepartment = async (req, res) => {
try {
const {departmentId} = req.params;
await prisma.department.delete({
where: {departmentId},
});
return res.status(200).json({
success: true,
message: "Department deleted successfully",
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to delete department",
});
}
};
@@ -0,0 +1,439 @@
import prisma from "../prisma/client.js";
// get doctors
export const getAllDoctors = async (req, res) => {
try {
const doctors = await prisma.doctor.findMany({
include: {
departments: {
include: {
department: true,
timing: true,
},
},
},
orderBy: {name: "asc"},
});
const formatted = doctors.map((doc, index) => ({
SL_NO: String(index + 1),
doctorId: doc.doctorId,
name: doc.name,
image: doc.image ?? "",
designation: doc.designation,
workingStatus: doc.workingStatus,
qualification: doc.qualification,
departments: doc.departments.map((d) => {
const t = d.timing || {};
const timingArray = [
t.monday && `Monday ${t.monday}`,
t.tuesday && `Tuesday ${t.tuesday}`,
t.wednesday && `Wednesday ${t.wednesday}`,
t.thursday && `Thursday ${t.thursday}`,
t.friday && `Friday ${t.friday}`,
t.saturday && `Saturday ${t.saturday}`,
t.sunday && `Sunday ${t.sunday}`,
t.additional && t.additional,
].filter(Boolean);
return {
departmentId: d.department.departmentId,
departmentName: d.department.name,
timing: timingArray.join(" & "),
};
}),
}));
res.status(200).json({
success: true,
data: formatted,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to fetch doctors",
});
}
};
// get doctor by id
export const getDoctorByDoctorId = async (req, res) => {
try {
const {doctorId} = req.params;
const doctor = await prisma.doctor.findUnique({
where: {doctorId},
include: {
departments: {
include: {
department: true,
timing: true,
},
},
},
});
if (!doctor) {
return res.status(404).json({
success: false,
message: "Doctor not found",
});
}
const response = {
doctorId: doctor.doctorId,
name: doctor.name,
image: doctor.image ?? "",
designation: doctor.designation,
workingStatus: doctor.workingStatus,
qualification: doctor.qualification,
departments: doctor.departments.map((d) => ({
departmentId: d.department.departmentId,
departmentName: d.department.name,
timing: d.timing || {},
})),
};
res.status(200).json({
success: true,
data: response,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to fetch doctor",
});
}
};
// get doctors by department
export const getDoctorsByDepartmentId = async (req, res) => {
try {
const {Department_ID} = req.query;
if (!Department_ID) {
return res.status(400).json({
success: false,
message: "Department_ID is required",
});
}
const department = await prisma.department.findUnique({
where: {departmentId: Department_ID},
});
if (!department) {
return res.status(404).json({
success: false,
message: "Department not found",
});
}
const doctors = await prisma.doctorDepartment.findMany({
where: {departmentId: department.id},
include: {
doctor: true,
},
});
const result = doctors.map((d) => ({
GG_ID: d.doctor.doctorId,
Name: d.doctor.name,
image: d.doctor.image ?? "",
}));
res.status(200).json({
success: true,
data: result,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to fetch doctors",
});
}
};
// add doctors
export const createDoctor = async (req, res) => {
try {
const {
doctorId,
name,
image,
designation,
workingStatus,
qualification,
departments,
} = req.body;
const doctor = await prisma.doctor.create({
data: {
doctorId,
name,
image,
designation,
workingStatus,
qualification,
},
});
for (const dep of departments) {
const department = await prisma.department.findUnique({
where: {departmentId: dep.departmentId},
});
if (!department) continue;
const doctorDepartment = await prisma.doctorDepartment.create({
data: {
doctorId: doctor.id,
departmentId: department.id,
},
});
if (dep.timing) {
await prisma.doctorTiming.create({
data: {
doctorDepartmentId: doctorDepartment.id,
...dep.timing,
},
});
}
}
res.status(201).json({
success: true,
message: "Doctor created successfully",
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to create doctor",
});
}
};
//update doctors
export const updateDoctor = async (req, res) => {
try {
const {doctorId} = req.params;
const {
name,
designation,
image,
workingStatus,
qualification,
departments,
} = req.body;
const doctor = await prisma.doctor.findUnique({
where: {doctorId},
});
if (!doctor) {
return res
.status(404)
.json({success: false, message: "Doctor not found"});
}
await prisma.doctor.update({
where: {id: doctor.id},
data: {name, designation, image, workingStatus, qualification},
});
const oldRelations = await prisma.doctorDepartment.findMany({
where: {doctorId: doctor.id},
});
for (const rel of oldRelations) {
await prisma.doctorTiming.deleteMany({
where: {doctorDepartmentId: rel.id},
});
}
await prisma.doctorDepartment.deleteMany({
where: {doctorId: doctor.id},
});
for (const dep of departments) {
const department = await prisma.department.findUnique({
where: {departmentId: dep.departmentId},
});
if (!department) continue;
const doctorDepartment = await prisma.doctorDepartment.create({
data: {
doctorId: doctor.id,
departmentId: department.id,
},
});
if (dep.timing) {
const {id, doctorDepartmentId, createdAt, updatedAt, ...cleanTiming} =
dep.timing;
await prisma.doctorTiming.create({
data: {
doctorDepartmentId: doctorDepartment.id,
...cleanTiming,
},
});
}
}
res
.status(200)
.json({success: true, message: "Doctor updated successfully"});
} catch (error) {
console.error("Update Error:", error);
res.status(500).json({success: false, message: "Failed to update doctor"});
}
};
//delete doctor
export const deleteDoctor = async (req, res) => {
try {
const {doctorId} = req.params;
const doctor = await prisma.doctor.findUnique({
where: {doctorId},
});
if (!doctor) {
return res.status(404).json({
success: false,
message: "Doctor not found",
});
}
const doctorDepartments = await prisma.doctorDepartment.findMany({
where: {doctorId: doctor.id},
});
for (const dd of doctorDepartments) {
await prisma.doctorTiming.deleteMany({
where: {doctorDepartmentId: dd.id},
});
}
await prisma.doctorDepartment.deleteMany({
where: {doctorId: doctor.id},
});
await prisma.doctor.delete({
where: {id: doctor.id},
});
res.status(200).json({
success: true,
message: "Doctor deleted successfully",
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to delete doctor",
});
}
};
// get doctor timings
export const getDoctorTimings = async (req, res) => {
try {
const doctors = await prisma.doctor.findMany({
include: {
departments: {
include: {
timing: true,
},
},
},
});
const result = doctors.map((doc) => {
const timing = doc.departments[0]?.timing || {};
return {
Doctor_ID: doc.doctorId,
Doctor: doc.name,
Monday: timing.monday || "",
Tuesday: timing.tuesday || "",
Wednesday: timing.wednesday || "",
Thursday: timing.thursday || "",
Friday: timing.friday || "",
Saturday: timing.saturday || "",
Sunday: timing.sunday || "",
Additional: timing.additional || "",
};
});
res.status(200).json({
success: true,
data: result,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to fetch doctor timings",
});
}
};
// get doctor timings by id
export const getDoctorTimingById = async (req, res) => {
try {
const {doctorId} = req.params;
const doctor = await prisma.doctor.findUnique({
where: {doctorId},
include: {
departments: {
include: {
department: true,
timing: true,
},
},
},
});
if (!doctor) {
return res.status(404).json({
success: false,
message: "Doctor not found",
});
}
const result = {
doctorId: doctor.doctorId,
doctorName: doctor.name,
departments: doctor.departments.map((d) => ({
departmentId: d.department.departmentId,
departmentName: d.department.name,
timing: d.timing || {},
})),
};
res.status(200).json({
success: true,
data: result,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to fetch doctor timing",
});
}
};
@@ -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",
});
}
};
@@ -0,0 +1,133 @@
import prisma from "../prisma/client.js";
import {sendEmail} from "../utils/sendEmail.js";
import {getEmailsByType} from "../utils/getEmailByTypes.js";
/* CREATE INQUIRY */
export const createInquiry = async (req, res) => {
try {
const {fullName, number, emailId, subject, message} = req.body;
if (!fullName || !number) {
return res.status(400).json({
success: false,
message: "Full name and number are required",
});
}
const inquiry = await prisma.inquiry.create({
data: {
fullName,
number,
emailId,
subject,
message,
},
});
try {
const emailList = await getEmailsByType("INQUIRY");
if (emailList && emailList.length > 0) {
await sendEmail({
to: emailList,
subject: "New Inquiry Received",
html: `
<h2>New Inquiry</h2>
<p><b>Name:</b> ${fullName}</p>
<p><b>Phone:</b> ${number}</p>
<p><b>Email:</b> ${emailId}</p>
<p><b>Subject:</b> ${subject}</p>
<p><b>Message:</b> ${message}</p>
`,
});
}
} catch (err) {
console.error("Inquiry email failed:", err);
}
res.status(200).json({
success: true,
status: 200,
data: inquiry,
message: "Inquiry added successfully",
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: "Failed to add inquiry",
});
}
};
/* GET ALL */
export const getInquiries = async (req, res) => {
try {
const inquiries = await prisma.inquiry.findMany({
orderBy: {
createdAt: "desc",
},
});
res.json({
success: true,
data: inquiries,
});
} catch (error) {
res.status(500).json({
success: false,
message: "Failed to fetch inquiries",
});
}
};
/* GET SINGLE */
export const getInquiry = async (req, res) => {
try {
const {id} = req.params;
const inquiry = await prisma.inquiry.findUnique({
where: {id: Number(id)},
});
if (!inquiry) {
return res.status(404).json({
success: false,
message: "Inquiry not found",
});
}
res.json({
success: true,
data: inquiry,
});
} catch (error) {
res.status(500).json({
success: false,
message: "Failed to fetch inquiry",
});
}
};
/* DELETE */
export const deleteInquiry = async (req, res) => {
try {
const {id} = req.params;
await prisma.inquiry.delete({
where: {id: Number(id)},
});
res.json({
success: true,
message: "Inquiry deleted successfully",
});
} catch (error) {
res.status(500).json({
success: false,
message: "Failed to delete inquiry",
});
}
};
@@ -0,0 +1,243 @@
import prisma from "../prisma/client.js";
// GET ALL NEWS
export const getAllNews = async (req, res) => {
try {
const page = parseInt(req.query.page);
const limit = parseInt(req.query.limit);
const includeImages = {
images: true,
};
if (!page && !limit) {
const news = await prisma.newsMedia.findMany({
include: includeImages,
orderBy: { createdAt: "desc" },
});
const response = news.map((n) => ({
Id: n.id.toString(),
Headline: n.headline,
Content: n.content,
FirstPara: n.firstPara,
SecondPara: n.secondPara,
Date: n.date,
Author: n.author,
Images: n.images.map((img) => ({
id: img.id,
image: img.url,
})),
}));
return res.status(200).json({
success: true,
data: response,
meta: null,
});
}
const currentPage = page || 1;
const currentLimit = limit || 10;
const skip = (currentPage - 1) * currentLimit;
const [news, total] = await Promise.all([
prisma.newsMedia.findMany({
include: includeImages,
orderBy: { createdAt: "desc" },
skip,
take: currentLimit,
}),
prisma.newsMedia.count(),
]);
const response = news.map((n) => ({
Id: n.id.toString(),
Headline: n.headline,
Content: n.content,
FirstPara: n.firstPara,
SecondPara: n.secondPara,
Date: n.date,
Author: n.author,
Images: n.images.map((img) => ({
id: img.id,
image: img.url,
})),
}));
return res.status(200).json({
success: true,
data: response,
meta: {
total,
page: currentPage,
limit: currentLimit,
totalPages: Math.ceil(total / currentLimit),
},
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to fetch news",
});
}
};
// GET NEWS BY ID
export const getNewsById = async (req, res) => {
try {
const { id } = req.params;
const n = await prisma.newsMedia.findUnique({
where: { id: Number(id) },
include: { images: true },
});
if (!n) {
return res.status(404).json({
success: false,
message: "News not found",
});
}
const response = {
Id: n.id.toString(),
Headline: n.headline,
Content: n.content,
FirstPara: n.firstPara,
SecondPara: n.secondPara,
Date: n.date,
Author: n.author,
Images: n.images.map((img) => ({
id: img.id,
image: img.url,
})),
};
return res.status(200).json({
success: true,
data: response,
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to fetch news",
});
}
};
// CREATE NEWS
export const createNews = async (req, res) => {
try {
const {
headline,
content,
firstPara,
secondPara,
date,
author,
imageUrls,
} = req.body;
if (!headline) {
return res.status(400).json({
success: false,
message: "Headline is required",
});
}
const news = await prisma.newsMedia.create({
data: {
headline,
content,
firstPara,
secondPara,
date: date ? new Date(date) : null,
author,
images: imageUrls
? {
create: imageUrls.map((url) => ({ url })),
}
: undefined,
},
include: { images: true },
});
return res.status(201).json({
success: true,
message: "News created successfully",
data: news,
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to create news",
});
}
};
// UPDATE NEWS
export const updateNews = async (req, res) => {
try {
const { id } = req.params;
const { imageUrls, ...otherData } = req.body;
const news = await prisma.newsMedia.update({
where: { id: Number(id) },
data: {
...otherData,
date: req.body.date ? new Date(req.body.date) : undefined,
images: imageUrls
? {
deleteMany: {},
create: imageUrls.map((url) => ({ url })),
}
: undefined,
},
include: { images: true },
});
return res.status(200).json({
success: true,
message: "News updated successfully",
data: news,
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to update news",
});
}
};
// DELETE NEWS
export const deleteNews = async (req, res) => {
try {
const { id } = req.params;
await prisma.newsMedia.delete({
where: { id: Number(id) },
});
return res.status(200).json({
success: true,
message: "News deleted successfully",
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to delete news",
});
}
};
@@ -0,0 +1,18 @@
import express from "express";
import {
createAcademicsResearch,
getAcademicsResearch,
getSingleAcademicsResearch,
deleteAcademicsResearch,
} from "../controllers/academicsResearch.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router();
router.post("/", createAcademicsResearch);
router.get("/getAll", jwtAuthMiddleware, getAcademicsResearch);
router.get("/:id", jwtAuthMiddleware, getSingleAcademicsResearch);
router.delete("/:id", jwtAuthMiddleware, deleteAcademicsResearch);
export default router;
+23
View File
@@ -0,0 +1,23 @@
import express from "express";
import {
createAppointment,
getAppointments,
getAppointment,
updateAppointment,
deleteAppointment,
} from "../controllers/appointment.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router();
/* PUBLIC */
router.get("/getall", jwtAuthMiddleware, getAppointments);
router.post("/", createAppointment);
router.get("/:id", jwtAuthMiddleware, getAppointment);
router.patch("/:id", jwtAuthMiddleware, updateAppointment);
router.delete("/:id", jwtAuthMiddleware, deleteAppointment);
export default router;
+5 -1
View File
@@ -6,6 +6,7 @@ import {
updateBlog,
deleteBlog,
getAllBlogs,
getBlogForAdmin,
} from "../controllers/blog.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js";
@@ -15,11 +16,14 @@ const router = express.Router();
/* PUBLIC */
router.get("/", getBlogs);
router.get("/:id", getBlog);
router.get("/:slug", getBlog);
// Protected
router.get("/admin/all", jwtAuthMiddleware, getAllBlogs);
router.get("/admin/:id", jwtAuthMiddleware, getBlogForAdmin);
router.post("/", jwtAuthMiddleware, createBlog);
router.put("/:id", jwtAuthMiddleware, updateBlog);
router.delete("/:id", jwtAuthMiddleware, deleteBlog);
+25
View File
@@ -0,0 +1,25 @@
import express from "express";
import {
createCandidate,
getCandidates,
getCandidate,
getCandidatesByCareer,
updateCandidate,
deleteCandidate,
} from "../controllers/candidate.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router();
/* PUBLIC */
router.post("/", createCandidate);
router.get("/getAll", jwtAuthMiddleware, getCandidates);
router.get("/:id", jwtAuthMiddleware, getCandidate);
router.get("/career/:careerId", jwtAuthMiddleware, getCandidatesByCareer);
router.patch("/:id", jwtAuthMiddleware, updateCandidate);
router.delete("/:id", jwtAuthMiddleware, deleteCandidate);
export default router;
+19
View File
@@ -0,0 +1,19 @@
import express from "express";
import {
getAllCareers,
createCareer,
updateCareer,
deleteCareer,
} from "../controllers/career.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router();
router.get("/getAll", getAllCareers);
router.post("/", jwtAuthMiddleware, createCareer);
router.patch("/:id", jwtAuthMiddleware, updateCareer);
router.delete("/:id", jwtAuthMiddleware, deleteCareer);
export default router;
+6
View File
@@ -1,7 +1,10 @@
import express from "express";
import {
getAllDepartments,
getDepartmentByName,
createDepartment,
updateDepartment,
deleteDepartment,
} from "../controllers/department.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js";
@@ -9,8 +12,11 @@ const router = express.Router();
// Public
router.get("/getAll", getAllDepartments);
router.get("/search", getDepartmentByName);
// Protected
router.post("/", jwtAuthMiddleware, createDepartment);
router.put("/:departmentId", jwtAuthMiddleware, updateDepartment);
router.delete("/:departmentId", jwtAuthMiddleware, deleteDepartment);
export default router;
+27
View File
@@ -0,0 +1,27 @@
import express from "express";
import {
getAllDoctors,
createDoctor,
updateDoctor,
deleteDoctor,
getDoctorTimings,
getDoctorTimingById,
getDoctorByDoctorId,
getDoctorsByDepartmentId,
} from "../controllers/doctor.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router();
router.get("/getAll", getAllDoctors);
router.get("/search", getDoctorsByDepartmentId);
router.get("/getTimings", getDoctorTimings);
router.get("/getTimings/:doctorId", getDoctorTimingById);
router.get("/:doctorId", getDoctorByDoctorId);
router.post("/", jwtAuthMiddleware, createDoctor);
router.patch("/:doctorId", jwtAuthMiddleware, updateDoctor);
router.delete("/:doctorId", jwtAuthMiddleware, deleteDoctor);
export default router;
+19
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;
+19
View File
@@ -0,0 +1,19 @@
import express from "express";
import {
createInquiry,
getInquiries,
getInquiry,
deleteInquiry,
} from "../controllers/inquiry.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router();
router.post("/", createInquiry);
router.get("/getAll", jwtAuthMiddleware, getInquiries);
router.get("/:id", jwtAuthMiddleware, getInquiry);
router.delete("/:id", jwtAuthMiddleware, deleteInquiry);
export default router;
+23
View File
@@ -0,0 +1,23 @@
import express from "express";
import {
createNews,
getAllNews,
getNewsById,
updateNews,
deleteNews,
} from "../controllers/newsMedia.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router();
// PUBLIC ROUTES
router.get("/getAll", getAllNews);
router.get("/:id", getNewsById);
// PROTECTED ROUTES
router.post("/", jwtAuthMiddleware, createNews);
router.patch("/:id", jwtAuthMiddleware, updateNews);
router.delete("/:id", jwtAuthMiddleware, deleteNews);
export default router;
+28 -8
View File
@@ -1,15 +1,35 @@
import express from "express";
import {upload} from "../controllers/upload.controller.js";
import * as Bytescale from "@bytescale/sdk";
import multer from "multer";
const router = express.Router();
router.post("/image", upload.single("image"), (req, res) => {
res.json({
success: 1,
file: {
url: `http://localhost:3000/uploads/blog/${req.file.filename}`,
},
});
const uploadManager = new Bytescale.UploadManager({
apiKey: process.env.BYTESCALE_SECRET_API_KEY,
});
const storage = multer.memoryStorage();
const upload = multer({storage});
router.post("/", upload.single("file"), async (req, res) => {
try {
const file = req.file;
const {folderPath} = req.body;
const result = await uploadManager.upload({
data: file.buffer,
name: file.originalname,
mime: file.mimetype,
path: {
folderPath: folderPath || "/general",
},
});
res.json({fileUrl: result.fileUrl});
} catch (error) {
console.error(error);
res.status(500).json({error: "Upload failed"});
}
});
export default router;
+17
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 "";
}
};
+18
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);
}
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+9678 -9403
View File
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -10,10 +10,23 @@
"preview": "vite preview"
},
"dependencies": {
"@bytescale/sdk": "^3.53.0",
"@editorjs/code": "^2.9.4",
"@editorjs/delimiter": "^1.4.2",
"@editorjs/editorjs": "^2.31.5",
"@editorjs/embed": "^2.8.0",
"@editorjs/header": "^2.8.8",
"@editorjs/image": "^2.10.3",
"@editorjs/list": "^2.0.9",
"@editorjs/quote": "^2.7.6",
"@editorjs/table": "^2.4.5",
"@fontsource-variable/geist": "^5.2.8",
"@tailwindcss/postcss": "^4.2.1",
"axios": "^1.13.6",
"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",
@@ -21,21 +34,24 @@
"react-router-dom": "^7.13.1",
"shadcn": "^4.0.5",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
"tailwindcss": "^4.2.1",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/estree": "^1.0.8",
"@types/json-schema": "^7.0.15",
"@types/node": "^24.12.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
+4 -5
View File
@@ -1,6 +1,5 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
plugins: {
"@tailwindcss/postcss": {},
},
};
+21 -21
View File
@@ -1,42 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
padding: 2em;
}
.read-the-docs {
color: #888;
color: #888;
}
+45 -33
View File
@@ -1,45 +1,57 @@
import {BrowserRouter, Routes, Route} from "react-router-dom";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import Login from "@/pages/Login";
import Dashboard from "@/pages/Dashboard";
import Blog from "@/pages/Blog";
import Department from "@/pages/Department";
import ProtectedRoute from "./components/ProtectedRoutes/ProtectedRoutes";
import DashboardLayout from "./layouts/DashboardLayout";
// import ProtectedRoute from "./components/ProtectedRoutes/ProtectedRoutes";
import ProtectedRoute from "./auth/ProtectedRoute";
import PublicRoute from "./auth/PublicRoute";
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";
import CandidatePage from "./pages/candidates";
import InquiryPage from "./pages/inquiry";
import AcademicsPage from "./pages/Academics";
import NewsPage from "./pages/newsMedia";
import BlogDetail from "./pages/BlogDetails";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Login />} />
<AuthProvider>
<Routes>
<Route element={<PublicRoute />}>
<Route path="/" element={<Login />} />
</Route>
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route element={<ProtectedRoute />}>
<Route element={<DashboardLayout />}>
<Route path="/department" element={<Department />} />
<Route path="/doctor" element={<Doctor />} />
<Route path="/blog" element={<Blog />} />
<Route path="/blog/:id" element={<BlogDetail />} />
<Route path="/blog/create" element={<BlogEditorPage />} />
<Route path="/blog/edit/:id" element={<BlogEditorPage />} />
<Route path="/appointment" element={<Appointment />} />
<Route path="/email" element={<EmailPage />} />
<Route path="/career" element={<CareerPage />} />
<Route path="/candidate" element={<CandidatePage />} />
<Route path="/inquiry" element={<InquiryPage />} />
<Route path="/academics" element={<AcademicsPage />} />
<Route path="/news" element={<NewsPage />} />
</Route>
</Route>
<Route
path="/blog"
element={
<ProtectedRoute>
<Blog />
</ProtectedRoute>
}
/>
<Route
path="/department"
element={
<ProtectedRoute>
<Department />
</ProtectedRoute>
}
/>
</Routes>
<Route path="*" element={<Navigate to="/department" replace />} />
</Routes>
</AuthProvider>
</BrowserRouter>
);
}
+11
View File
@@ -0,0 +1,11 @@
import apiClient from "@/api/client";
export const getAcademicsApi = async () => {
const res = await apiClient.get("/academics/getAll");
return res.data;
};
export const deleteAcademicsApi = async (id: number) => {
const res = await apiClient.delete(`/academics/${id}`);
return res.data;
};
+11
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;
};
+12
View File
@@ -0,0 +1,12 @@
import apiClient from "./client";
export const loginApi = async (
username: string,
password: string,
): Promise<any> => {
const response = await apiClient.post("/auth/login/", {
username,
password,
});
return response.data;
};
+48
View File
@@ -0,0 +1,48 @@
import apiClient from "@/api/client";
export interface Blog {
id?: number;
title: string;
writer: string;
image?: string;
content: any;
}
export const getAllBlogsApi = async () => {
const res = await apiClient.get("/blogs");
return res.data;
};
export const getBlogByIdApi = async (id: number) => {
const res = await apiClient.get(`/blogs/admin/${id}`);
return res.data;
};
export const createBlogApi = async (data: Blog) => {
const res = await apiClient.post("/blogs", data);
return res.data;
};
export const updateBlogApi = async (id: number, data: Blog) => {
const res = await apiClient.put(`/blogs/${id}`, data);
return res.data;
};
export const deleteBlogApi = async (id: number) => {
const res = await apiClient.delete(`/blogs/${id}`);
return res.data;
};
/* IMAGE UPLOAD */
export const uploadImageApi = async (file: File) => {
const formData = new FormData();
formData.append("image", file);
const res = await apiClient.post("/upload/image", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return res.data;
};
+11
View File
@@ -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;
};
+11
View File
@@ -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;
};
+48
View File
@@ -0,0 +1,48 @@
import axios from "axios";
import type {InternalAxiosRequestConfig} from "axios";
const BASE_URL: string = "http://localhost:3000/api";
const apiClient = axios.create({
baseURL: BASE_URL,
headers: {
"Content-Type": "application/json",
},
});
export const setAxiosAuthToken = (token: string | null): void => {
if (token) {
apiClient.defaults.headers.common["Authorization"] = `Bearer ${token}`;
} else {
delete apiClient.defaults.headers.common["Authorization"];
}
};
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem("token");
if (token && config.headers) {
config.headers["Authorization"] = `Bearer ${token}`;
}
return config;
},
(error: any) => Promise.reject(error),
);
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
console.error("Unauthorized - token missing or invalid");
localStorage.removeItem("token");
window.location.href = "/login";
}
return Promise.reject(error);
},
);
export default apiClient;
+50
View File
@@ -0,0 +1,50 @@
import apiClient from "@/api/client";
export interface Department {
departmentId: string;
name: string;
image?: string;
para1: string;
para2: string;
para3: string;
facilities: string;
services: string;
}
export const getDepartmentsApi = async () => {
const res = await apiClient.get("/departments/getAll");
return res.data;
};
export const createDepartmentApi = async (data: {
departmentId: string;
name: string;
para1?: string;
para2?: string;
para3?: string;
facilities?: string;
services?: string;
}) => {
const res = await apiClient.post("/departments", data);
return res.data;
};
export const updateDepartmentApi = async (
departmentId: string,
data: {
name?: string;
para1?: string;
para2?: string;
para3?: string;
facilities?: string;
services?: string;
},
) => {
const res = await apiClient.put(`/departments/${departmentId}`, data);
return res.data;
};
export const deleteDepartmentApi = async (departmentId: string) => {
const res = await apiClient.delete(`/departments/${departmentId}`);
return res.data;
};
+57
View File
@@ -0,0 +1,57 @@
import apiClient from "@/api/client";
export interface Doctor {
doctorId: string;
name: string;
image?: string;
designation?: string;
workingStatus?: string;
qualification?: string;
departments: {
departmentId: string;
timing?: {
monday?: string;
tuesday?: string;
wednesday?: string;
thursday?: string;
friday?: string;
saturday?: string;
sunday?: string;
additional?: string;
};
}[];
}
export const getDoctorsApi = async () => {
const res = await apiClient.get("/doctors/getAll");
return res.data;
};
export const getDoctorByIdApi = async (doctorId: string) => {
const res = await apiClient.get(`/doctors/${doctorId}`);
return res.data;
};
export const createDoctorApi = async (data: Doctor) => {
const res = await apiClient.post("/doctors", data);
return res.data;
};
export const updateDoctorApi = async (
doctorId: string,
data: Partial<Doctor>,
) => {
const res = await apiClient.patch(`/doctors/${doctorId}`, data);
return res.data;
};
export const deleteDoctorApi = async (doctorId: string) => {
const res = await apiClient.delete(`/doctors/${doctorId}`);
return res.data;
};
export const getDoctorTimingApi = async (doctorId: string) => {
const res = await apiClient.get(`/doctors/getTimings/${doctorId}`);
return res.data;
};
+36
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;
};
+11
View File
@@ -0,0 +1,11 @@
import apiClient from "@/api/client";
export const getInquiriesApi = async () => {
const res = await apiClient.get("/inquiry/getAll");
return res.data;
};
export const deleteInquiryApi = async (id: number) => {
const res = await apiClient.delete(`/inquiry/${id}`);
return res.data;
};
+23
View File
@@ -0,0 +1,23 @@
import apiClient from "@/api/client";
export const getNewsApi = async (page = 1, limit = 10) => {
const res = await apiClient.get(
`/newsMedia/getAll?page=${page}&limit=${limit}`,
);
return res.data;
};
export const createNewsApi = async (data: any) => {
const res = await apiClient.post("/newsMedia", data);
return res.data;
};
export const updateNewsApi = async (id: number, data: any) => {
const res = await apiClient.patch(`/newsMedia/${id}`, data);
return res.data;
};
export const deleteNewsApi = async (id: number) => {
const res = await apiClient.delete(`/newsMedia/${id}`);
return res.data;
};
+7
View File
@@ -0,0 +1,7 @@
import {Navigate, Outlet} from "react-router-dom";
import {useAuth} from "@/context/AuthContext";
export default function ProtectedRoute() {
const {token} = useAuth();
return token ? <Outlet /> : <Navigate to="/" replace />;
}
+7
View File
@@ -0,0 +1,7 @@
import {Navigate, Outlet} from "react-router-dom";
import {useAuth} from "@/context/AuthContext";
export default function PublicRoute() {
const {token} = useAuth();
return token ? <Navigate to="/dashboard" replace /> : <Outlet />;
}
@@ -0,0 +1,125 @@
import {useState, useRef} from "react";
import {Button} from "@/components/ui/button";
import {User, X, Loader2} from "lucide-react";
import axios from "axios";
interface BytescaleUploaderProps {
value: string;
onChange: (url: string) => void;
folderPath: "/doctors" | "/departments" | "/news";
}
export function BytescaleUploader({
value,
onChange,
folderPath,
}: BytescaleUploaderProps) {
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const onFileSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
alert("File is too large (Max 5MB)");
return;
}
setIsUploading(true);
const formData = new FormData();
formData.append("file", file);
formData.append("folderPath", folderPath);
try {
const response = await axios.post(
"http://localhost:3000/api/upload",
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
},
);
const {fileUrl} = response.data;
onChange(fileUrl);
} catch (e: any) {
console.error("Upload Error:", e);
const errorMessage =
e.response?.data?.error || e.message || "Upload failed";
alert(`Upload Error: ${errorMessage}`);
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
return (
<div className="flex flex-col gap-2 p-3 border rounded-md bg-muted/5">
<div className="flex items-center gap-4">
<div className="relative">
{value ? (
<>
<img
src={value}
className="w-16 h-16 rounded-full object-cover border-2 border-primary/20"
alt="Preview"
/>
<button
type="button"
onClick={() => onChange("")}
className="absolute -top-1 -right-1 bg-destructive text-white rounded-full p-0.5 shadow-sm hover:scale-110 transition-transform"
>
<X className="w-3 h-3" />
</button>
</>
) : (
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center">
{isUploading ? (
<Loader2 className="w-8 h-8 animate-spin text-primary" />
) : (
<User className="w-8 h-8 text-muted-foreground" />
)}
</div>
)}
</div>
<input
type="file"
ref={fileInputRef}
onChange={onFileSelected}
accept="image/jpeg,image/png,image/webp"
className="hidden"
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={isUploading}
onClick={() => fileInputRef.current?.click()}
>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Uploading...
</>
) : value ? (
"Change Photo"
) : (
"Upload Photo"
)}
</Button>
</div>
{value && (
<p className="text-xs text-amber-600 pl-[72px]">
Make sure to save the changes by clicking the "Save Changes"
button.
</p>
)}
</div>
);
}
@@ -1,20 +0,0 @@
import Sidebar from "./Sidebar"
export default function DashboardLayout({children}:{children:React.ReactNode}){
return(
<div className="flex">
<Sidebar/>
<div className="flex-1 p-6 bg-slate-50 min-h-screen">
{children}
</div>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import {useState, useEffect} from "react";
import {useAuth} from "@/context/AuthContext";
import {Button} from "@/components/ui/button";
import {Switch} from "@/components/ui/switch";
import {log} from "console";
export default function Header() {
const {user, logout} = useAuth();
const [darkMode, setDarkMode] = useState<boolean>(false);
useEffect(() => {
if (darkMode) document.documentElement.classList.add("dark");
else document.documentElement.classList.remove("dark");
}, [darkMode]);
return (
<header className="border-b bg-card">
<div className="flex items-center justify-between px-6 h-16">
<div>
<p className="text-sm text-muted-foreground">Welcome back</p>
<p className="font-semibold">{user?.username}</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm">Dark</span>
<Switch checked={darkMode} onCheckedChange={setDarkMode} />
</div>
<Button variant="destructive" onClick={logout}>
Logout
</Button>
</div>
</div>
</header>
);
}
+70 -10
View File
@@ -1,17 +1,77 @@
import {Link} from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
export default function Sidebar() {
const location = useLocation();
const navItems = [
{
name: "Department",
path: "/department",
},
{
name: "Doctor",
path: "/doctor",
},
{
name: "Appointments",
path: "/appointment",
},
{
name: "Career",
path: "/career",
},
{
name: "Candidates",
path: "/candidate",
},
{
name: "Inquiry",
path: "/inquiry",
},
{
name: "Academics & Research",
path: "/academics",
},
{
name: "News & Media",
path: "/news",
},
{
name: "Email",
path: "/email",
},
{
name: "Blog",
path: "/blog",
},
];
return (
<div className="w-[220px] h-screen border-r bg-white p-4">
<h2 className="text-lg font-semibold mb-6">Admin</h2>
<div className="space-y-3">
<Link to="/dashboard">Dashboard</Link>
<Link to="/blog">Blog</Link>
<Link to="/department">Department</Link>
<div className="w-64 border-r bg-card">
<div className="p-6">
<h2 className="text-xl font-bold">GG Dashboard</h2>
</div>
<Separator />
<nav className="p-4 space-y-2">
{navItems.map((item) => {
const active = location.pathname === item.path;
return (
<Link key={item.path} to={item.path}>
<Button
variant={active ? "secondary" : "ghost"}
className="w-full justify-start">
{item.name}
</Button>
</Link>
);
})}
</nav>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+193
View File
@@ -0,0 +1,193 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+163
View File
@@ -0,0 +1,163 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+156
View File
@@ -0,0 +1,156 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+87
View File
@@ -0,0 +1,87 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
@@ -0,0 +1,53 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react";
import {Switch as SwitchPrimitive} from "radix-ui";
import {cn} from "@/lib/utils";
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default";
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
);
}
export {Switch};
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+124 -3
View File
@@ -1,3 +1,124 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+18
View File
@@ -0,0 +1,18 @@
import {Outlet} from "react-router-dom";
import Sidebar from "@/components/layout/Sidebar";
import Header from "@/components/layout/Header";
export default function DashboardLayout() {
return (
<div className="flex min-h-screen bg-background">
<Sidebar />
<div className="flex flex-col flex-1">
<Header />
<main className="flex-1 p-6">
<Outlet />
</main>
</div>
</div>
);
}
+364
View File
@@ -0,0 +1,364 @@
import { useState, useEffect, useCallback } from "react";
import { getAcademicsApi, deleteAcademicsApi } from "@/api/academics";
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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Loader2,
Trash,
RefreshCw,
Download,
ChevronLeft,
ChevronRight,
Eye,
BookOpen,
} from "lucide-react";
export default function AcademicsPage() {
const [records, setRecords] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState("");
const [viewOpen, setViewOpen] = useState(false);
const [viewData, setViewData] = useState<any>(null);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const fetchAll = useCallback(async () => {
setLoading(true);
try {
const res = await getAcademicsApi();
setRecords(res?.data || []);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAll();
}, [fetchAll]);
const filteredRecords = records.filter((item) => {
return (
item.fullName?.toLowerCase().includes(searchText.toLowerCase()) ||
item.number?.includes(searchText) ||
item.emailId?.toLowerCase().includes(searchText.toLowerCase()) ||
item.subject?.toLowerCase().includes(searchText.toLowerCase()) ||
item.courseName?.toLowerCase().includes(searchText.toLowerCase())
);
});
useEffect(() => {
setCurrentPage(1);
}, [searchText]);
const totalPages = Math.ceil(filteredRecords.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredRecords.slice(indexOfFirstItem, indexOfLastItem);
function openView(item: any) {
setViewData(item);
setViewOpen(true);
}
async function handleDelete(id: number) {
if (!confirm("Delete record?")) return;
await deleteAcademicsApi(id);
fetchAll();
}
const handleExport = () => {
const exportData = filteredRecords.map((item) => ({
ID: item.id,
Name: item.fullName,
Phone: item.number,
Email: item.emailId,
Course: item.courseName,
Subject: item.subject,
Message: item.message,
Date: new Date(item.createdAt).toLocaleDateString(),
}));
exportToExcel(exportData, "academics");
};
return (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<h1 className="text-3xl font-bold">Academics & Research</h1>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search name / course / email..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[280px] text-base"
/>
<Button
variant="outline"
onClick={fetchAll}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={handleExport} className="text-base">
<Download className="mr-2 h-5 w-5" />
Export
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-xl">Academic Records</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[1100px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow>
<TableHead className="w-[60px] bg-background font-bold text-sm">
ID
</TableHead>
<TableHead className="w-[220px] bg-background font-bold text-sm">
Full Name
</TableHead>
<TableHead className="w-[180px] bg-background font-bold text-sm">
Course
</TableHead>
<TableHead className="w-[180px] bg-background font-bold text-sm">
Subject
</TableHead>
<TableHead className="w-[140px] bg-background font-bold text-sm">
Applied Date
</TableHead>
<TableHead className="w-[220px] bg-background font-bold text-sm">
Message
</TableHead>
<TableHead className="w-[120px] bg-background font-bold text-right text-sm">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : currentItems.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground py-10 text-base"
>
No records found
</TableCell>
</TableRow>
) : (
currentItems.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs">
{item.id}
</TableCell>
<TableCell>
<div className="font-semibold text-base truncate">
{item.fullName}
</div>
<div className="text-xs text-muted-foreground truncate">
{item.emailId}
</div>
<div className="text-[11px] font-medium">
{item.number}
</div>
</TableCell>
<TableCell>
<div className="text-sm font-medium line-clamp-2">
{item.courseName || "-"}
</div>
</TableCell>
<TableCell>
<div className="text-sm line-clamp-2">
{item.subject || "-"}
</div>
</TableCell>
<TableCell className="text-sm">
{new Date(item.createdAt).toLocaleDateString()}
</TableCell>
<TableCell>
<div className="text-sm line-clamp-2 text-muted-foreground italic">
{item.message || "-"}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={() => openView(item)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(item.id)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && filteredRecords.length > 0 && (
<div className="flex items-center justify-between px-2 py-6 border-t">
<div className="text-base text-muted-foreground">
Showing{" "}
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
<span className="font-semibold">
{Math.min(indexOfLastItem, filteredRecords.length)}
</span>{" "}
of{" "}
<span className="font-semibold">{filteredRecords.length}</span>{" "}
records
</div>
<div className="flex items-center gap-6">
<div className="text-base font-semibold">
Page {currentPage} of {totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
disabled={currentPage === 1}
>
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages || totalPages === 0}
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
<DialogContent className="w-full !max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl border-b pb-2 flex items-center gap-2">
<BookOpen className="h-6 w-6" /> Academic Detail View
</DialogTitle>
</DialogHeader>
{viewData && (
<div className="space-y-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Applicant Information
</p>
<p className="text-lg font-bold text-primary">
{viewData.fullName}
</p>
<p className="text-sm font-medium">{viewData.emailId}</p>
<p className="text-sm">{viewData.number}</p>
</div>
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Course & Subject
</p>
<p className="text-base font-semibold">
{viewData.courseName || "N/A"}
</p>
<p className="text-sm text-muted-foreground">
{viewData.subject}
</p>
</div>
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Submission Date
</p>
<p className="text-sm">
{new Date(viewData.createdAt).toLocaleString()}
</p>
</div>
</div>
<div className="space-y-4">
<div className="p-4 bg-muted/30 rounded-lg border">
<p className="text-xs uppercase font-bold text-muted-foreground mb-2">
Message / Research Inquiry
</p>
<p className="text-sm leading-relaxed whitespace-pre-wrap italic">
{viewData.message || "No message content provided."}
</p>
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<Button
onClick={() => setViewOpen(false)}
className="w-full md:w-auto"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+381
View File
@@ -0,0 +1,381 @@
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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Loader2,
Trash,
RefreshCw,
Download,
ChevronLeft,
ChevronRight,
Eye,
} 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 [filterDate, setFilterDate] = useState("");
const [viewOpen, setViewOpen] = useState(false);
const [viewData, setViewData] = useState<any>(null);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
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 matchesDate = filterDate
? new Date(item.date).toISOString().split("T")[0] === filterDate
: true;
return matchesSearch && matchesDoctor && matchesDate;
});
useEffect(() => {
setCurrentPage(1);
}, [searchText, filterDoctor, filterDate]);
const totalPages = Math.ceil(filteredAppointments.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredAppointments.slice(
indexOfFirstItem,
indexOfLastItem,
);
function openView(item: any) {
setViewData(item);
setViewOpen(true);
}
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 flex-col md:flex-row md:justify-between md:items-center gap-4">
<h1 className="text-3xl font-bold">Appointments</h1>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search name / phone..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[220px] text-base"
/>
<Input
type="date"
value={filterDate}
onChange={(e) => setFilterDate(e.target.value)}
className="w-[160px] text-base"
/>
<Button
variant="outline"
onClick={fetchAll}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={handleExport} className="text-base">
<Download className="mr-2 h-5 w-5" />
Export
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-xl">Appointment List</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[1000px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow>
<TableHead className="w-[60px] bg-background font-bold text-sm">
ID
</TableHead>
<TableHead className="w-[200px] bg-background font-bold text-sm">
Patient
</TableHead>
<TableHead className="w-[180px] bg-background font-bold text-sm">
Doctor
</TableHead>
<TableHead className="w-[150px] bg-background font-bold text-sm">
Date
</TableHead>
<TableHead className="w-[250px] bg-background font-bold text-sm">
Message
</TableHead>
<TableHead className="w-[120px] bg-background font-bold text-right text-sm">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : currentItems.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground py-10 text-base"
>
No appointments found
</TableCell>
</TableRow>
) : (
currentItems.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs">
{item.id}
</TableCell>
<TableCell>
<div className="font-semibold text-base truncate">
{item.name}
</div>
<div className="text-xs text-muted-foreground">
{item.mobileNumber}
</div>
</TableCell>
<TableCell>
<div className="text-sm font-medium">
{item.doctor?.name || "-"}
</div>
<div className="text-[10px] text-muted-foreground truncate">
{item.department?.name}
</div>
</TableCell>
<TableCell>
<div className="text-sm">
{new Date(item.date).toLocaleDateString()}
</div>
</TableCell>
<TableCell>
<div className="text-sm line-clamp-2 text-muted-foreground italic">
{item.message || "-"}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={() => openView(item)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(item.id)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && filteredAppointments.length > 0 && (
<div className="flex items-center justify-between px-2 py-6 border-t">
<div className="text-base text-muted-foreground">
Showing{" "}
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
<span className="font-semibold">
{Math.min(indexOfLastItem, filteredAppointments.length)}
</span>{" "}
of{" "}
<span className="font-semibold">
{filteredAppointments.length}
</span>
</div>
<div className="flex items-center gap-6">
<div className="text-base font-semibold">
Page {currentPage} of {totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
disabled={currentPage === 1}
>
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages || totalPages === 0}
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
<DialogContent className="w-full !max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl border-b pb-2">
Appointment Details
</DialogTitle>
</DialogHeader>
{viewData && (
<div className="space-y-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Patient Information
</p>
<p className="text-lg font-bold text-primary">
{viewData.name}
</p>
<p className="text-sm">{viewData.mobileNumber}</p>
<p className="text-sm">
{viewData.email || "No email provided"}
</p>
</div>
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Appointment Date
</p>
<p className="text-base font-semibold">
{new Date(viewData.date).toLocaleDateString()}
</p>
<p className="text-[10px] text-muted-foreground">
Booked on: {new Date(viewData.createdAt).toLocaleString()}
</p>
</div>
</div>
<div className="space-y-4">
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Doctor / Department
</p>
<p className="text-base font-bold">
{viewData.doctor?.name || "Not Assigned"}
</p>
<p className="text-sm text-muted-foreground">
{viewData.department?.name || "General"}
</p>
</div>
<div className="p-4 bg-muted/30 rounded-lg">
<p className="text-xs uppercase font-bold text-muted-foreground mb-2">
Message from Patient
</p>
<p className="text-sm italic leading-relaxed whitespace-pre-wrap">
{viewData.message || "No message provided."}
</p>
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<Button
onClick={() => setViewOpen(false)}
className="w-full md:w-auto"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+184 -8
View File
@@ -1,13 +1,189 @@
import DashboardLayout from "@/components/layout/DashboardLayout";
import {useState, useEffect, useCallback} from "react";
import {AxiosError} from "axios";
import {useNavigate} from "react-router-dom";
import {getAllBlogsApi, deleteBlogApi} from "@/api/blog";
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, RefreshCw, Plus, Pencil, Trash, Eye} from "lucide-react";
interface Blog {
id: number;
title: string;
writer: string;
image: string | null;
}
export default function BlogPage() {
const [blogs, setBlogs] = useState<Blog[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [searchText, setSearchText] = useState("");
const navigate = useNavigate();
const fetchBlogs = useCallback(async () => {
setLoading(true);
setError("");
try {
const res = await getAllBlogsApi();
if (Array.isArray(res)) {
setBlogs(res);
} else {
setBlogs([]);
}
} catch (err) {
if (err instanceof AxiosError) {
setError(err.response?.data?.message || "Failed to load blogs");
} else {
setError("Something went wrong");
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchBlogs();
}, [fetchBlogs]);
const filteredBlogs = blogs.filter((b) => {
const text = searchText.toLowerCase();
return (
b.title?.toLowerCase().includes(text) ||
b.writer?.toLowerCase().includes(text)
);
});
async function handleDelete(id: number) {
const confirmDelete = confirm("Delete this blog?");
if (!confirmDelete) return;
try {
await deleteBlogApi(id);
fetchBlogs();
} catch (error) {
console.error(error);
}
}
export default function Blog() {
return (
<DashboardLayout>
<h1 className="text-xl font-semibold mb-4">Blog Management</h1>
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-3">
<h1 className="text-2xl font-bold">Blogs</h1>
<button className="px-4 py-2 bg-black text-white rounded">
Create Blog
</button>
</DashboardLayout>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search blog..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[220px]"
/>
<Button variant="outline" onClick={fetchBlogs} disabled={loading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
<Button onClick={() => navigate("/blog/create")}>
<Plus className="mr-2 h-4 w-4" />
Add Blog
</Button>
</div>
</div>
{error && (
<div className="p-4 text-red-600 bg-red-50 border rounded-md">
{error}
</div>
)}
<Card>
<CardHeader>
<CardTitle>Blog List</CardTitle>
</CardHeader>
<CardContent>
<div className="border rounded-md overflow-x-auto max-w-full">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Title</TableHead>
<TableHead>Writer</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={4} className="text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : filteredBlogs.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center">
No blogs found
</TableCell>
</TableRow>
) : (
filteredBlogs.map((blog) => (
<TableRow key={blog.id}>
<TableCell>{blog.id}</TableCell>
<TableCell>{blog.title}</TableCell>
<TableCell>{blog.writer}</TableCell>
<TableCell className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/blog/edit/${blog.id}`)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => navigate(`/blog/${blog.id}`)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(blog.id)}
>
<Trash className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import React, {useEffect, useState} from "react";
import {useParams, useNavigate} from "react-router-dom";
import axios from "axios";
import {Button} from "@/components/ui/button";
import {Card, CardContent} from "@/components/ui/card";
import {getBlogByIdApi} from "@/api/blog";
const BlogDetail = () => {
const {id} = useParams();
const navigate = useNavigate();
const [blog, setBlog] = useState<any>(null);
const fetchBlogById = async () => {
try {
const response = await getBlogByIdApi(Number(id));
setBlog(response);
} catch (error) {
console.error("Error fetching blog", error);
}
};
useEffect(() => {
fetchBlogById();
}, [id]); // ✅ FIXED dependency
if (!blog) {
return <div className="mt-40 text-center text-gray-500">Loading...</div>;
}
return (
<div className=" mx-auto flex flex-col ">
<Card>
<CardContent className="p-6 space-y-4">
{/* Back */}
<Button
variant="ghost"
className="text-black-600 p-0"
onClick={() => navigate(-1)}
>
Back
</Button>
{/* Title */}
<h1 className="text-2xl md:text-4xl lg:text-5xl font-bold">
{blog.title}
</h1>
{/* Meta */}
<p className="text-gray-500 text-sm">
{blog.writer} {new Date(blog.createdAt).toLocaleDateString()}
</p>
{/* Image */}
<img
src={blog.image}
alt={blog.title}
className="w-full h-[220px] md:h-[400px] object-cover rounded-md"
/>
{/* Content */}
<div className="space-y-3 text-gray-800 leading-relaxed">
{blog.content?.blocks?.map((block: any, index: number) => (
<p key={index}>{block.data?.text}</p>
))}
</div>
</CardContent>
</Card>
</div>
);
};
export default BlogDetail;
+208
View File
@@ -0,0 +1,208 @@
import {useEffect, useRef, useState} from "react";
import {useNavigate, useParams} from "react-router-dom";
import EditorJS, {OutputData} from "@editorjs/editorjs";
import Header from "@editorjs/header";
import List from "@editorjs/list";
import ImageTool from "@editorjs/image";
import Quote from "@editorjs/quote";
import Table from "@editorjs/table";
import CodeTool from "@editorjs/code";
import Embed from "@editorjs/embed";
import Delimiter from "@editorjs/delimiter";
import {
createBlogApi,
updateBlogApi,
getBlogByIdApi,
uploadImageApi,
} from "@/api/blog";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Input} from "@/components/ui/input";
import {Button} from "@/components/ui/button";
export default function BlogEditorPage() {
const {id} = useParams();
const navigate = useNavigate();
const editorRef = useRef<EditorJS | null>(null);
const hasInitialized = useRef(false);
const hasRenderedContent = useRef(false);
const [title, setTitle] = useState("");
const [writer, setWriter] = useState("");
const [coverImage, setCoverImage] = useState("");
const [loading, setLoading] = useState(false);
const isEdit = Boolean(id);
useEffect(() => {
if (hasInitialized.current) return;
hasInitialized.current = true;
let editor: EditorJS;
const initEditor = async () => {
editor = new EditorJS({
holder: "editorjs",
placeholder: "Write blog content...",
tools: {
header: {
class: Header,
inlineToolbar: true,
config: {
placeholder: "Enter heading",
levels: [1, 2, 3, 4],
defaultLevel: 2,
},
},
list: {
class: List,
inlineToolbar: true,
config: {
defaultStyle: "unordered",
},
},
quote: Quote,
table: Table,
code: CodeTool,
embed: Embed,
delimiter: Delimiter,
image: {
class: ImageTool,
config: {
uploader: {
uploadByFile: async (file: File) => {
const res = await uploadImageApi(file);
return {
success: 1,
file: {url: res.file.url},
};
},
},
},
},
},
});
await editor.isReady;
editorRef.current = editor;
if (isEdit && id && !hasRenderedContent.current) {
try {
const res = await getBlogByIdApi(Number(id));
setTitle(res.title);
setWriter(res.writer);
setCoverImage(res.image || "");
if (res.content) {
await editor.blocks.clear();
await editor.render(res.content);
hasRenderedContent.current = true;
}
} catch (err) {
console.error(err);
}
}
};
initEditor();
}, [id, isEdit]);
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const res = await uploadImageApi(file);
setCoverImage(res.file.url);
} catch (err) {
console.error(err);
}
};
const handleSave = async () => {
if (!editorRef.current) return;
setLoading(true);
try {
const content: OutputData = await editorRef.current.save();
const payload = {
title,
writer,
image: coverImage,
content,
isActive: true,
};
if (isEdit) {
await updateBlogApi(Number(id), payload);
} else {
await createBlogApi(payload);
}
navigate("/blog");
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<Card>
<CardHeader>
<CardTitle>{isEdit ? "Edit Blog" : "Create Blog"}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input
placeholder="Blog Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Input
placeholder="Writer Name"
value={writer}
onChange={(e) => setWriter(e.target.value)}
/>
<div className="space-y-2">
<label className="text-sm font-medium">Cover Image</label>
<Input type="file" onChange={handleCoverUpload} />
{coverImage && (
<img
src={coverImage}
alt="cover"
className="w-full max-h-[250px] object-cover rounded-md"
/>
)}
</div>
<div
id="editorjs"
className="border rounded-md p-4 bg-white min-h-[300px]"
/>
<Button onClick={handleSave} disabled={loading}>
{loading ? "Saving..." : "Save Blog"}
</Button>
</CardContent>
</Card>
</div>
);
}
+415
View File
@@ -0,0 +1,415 @@
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 { Badge } from "@/components/ui/badge";
import {
Loader2,
Plus,
Pencil,
Trash,
RefreshCw,
ChevronLeft,
ChevronRight,
} from "lucide-react";
export default function CareerPage() {
const [careers, setCareers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [openModal, setOpenModal] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [searchText, setSearchText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
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()) ||
item.designation?.toLowerCase().includes(searchText.toLowerCase()),
);
useEffect(() => {
setCurrentPage(1);
}, [searchText]);
const totalPages = Math.ceil(filteredCareers.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredCareers.slice(indexOfFirstItem, indexOfLastItem);
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 (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<h1 className="text-3xl font-bold">Careers</h1>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search post / designation..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[250px] text-base"
/>
<Button
variant="outline"
onClick={fetchAll}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={openAdd} className="text-base">
<Plus className="mr-2 h-5 w-5" />
Add Career
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-xl">Career Opportunities</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[1000px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow>
<TableHead className="w-[60px] bg-background font-bold text-sm">
ID
</TableHead>
<TableHead className="w-[200px] bg-background font-bold text-sm">
Post & Designation
</TableHead>
<TableHead className="w-[200px] bg-background font-bold text-sm">
Qualification
</TableHead>
<TableHead className="w-[120px] bg-background font-bold text-sm">
Experience
</TableHead>
<TableHead className="w-[200px] bg-background font-bold text-sm">
Contact Info
</TableHead>
<TableHead className="w-[100px] bg-background font-bold text-sm">
Status
</TableHead>
<TableHead className="w-[120px] bg-background font-bold text-right text-sm">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : currentItems.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground py-10 text-base"
>
No careers found
</TableCell>
</TableRow>
) : (
currentItems.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs">
{item.id}
</TableCell>
<TableCell>
<div className="font-semibold text-base truncate">
{item.post}
</div>
<div className="text-xs text-muted-foreground truncate">
{item.designation}
</div>
</TableCell>
<TableCell>
<div className="text-sm line-clamp-2">
{item.qualification}
</div>
</TableCell>
<TableCell className="text-sm">
{item.experienceNeed}
</TableCell>
<TableCell>
<div className="text-sm font-medium">{item.email}</div>
<div className="text-xs text-muted-foreground">
{item.number}
</div>
</TableCell>
<TableCell>
<Badge
variant={
item.status === "active" ? "default" : "secondary"
}
className="capitalize"
>
{item.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={() => openEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(item.id)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && filteredCareers.length > 0 && (
<div className="flex items-center justify-between px-2 py-6 border-t">
<div className="text-base text-muted-foreground">
Showing{" "}
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
<span className="font-semibold">
{Math.min(indexOfLastItem, filteredCareers.length)}
</span>{" "}
of{" "}
<span className="font-semibold">{filteredCareers.length}</span>{" "}
careers
</div>
<div className="flex items-center gap-6">
<div className="text-base font-semibold">
Page {currentPage} of {totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
disabled={currentPage === 1}
>
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages || totalPages === 0}
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent className="w-full max-w-lg">
<DialogHeader>
<DialogTitle className="text-2xl">
{editing ? "Edit Career" : "Add New Career"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-1 gap-4">
<Input
name="post"
placeholder="Post (e.g. Staff Nurse)"
value={form.post}
onChange={handleChange}
className="text-base"
/>
<Input
name="designation"
placeholder="Designation"
value={form.designation}
onChange={handleChange}
className="text-base"
/>
<Input
name="qualification"
placeholder="Qualification"
value={form.qualification}
onChange={handleChange}
className="text-base"
/>
<Input
name="experienceNeed"
placeholder="Experience Needed"
value={form.experienceNeed}
onChange={handleChange}
className="text-base"
/>
<Input
name="email"
type="email"
placeholder="HR Email Address"
value={form.email}
onChange={handleChange}
className="text-base"
/>
<Input
name="number"
placeholder="Contact Number"
value={form.number}
onChange={handleChange}
className="text-base"
/>
<Input
name="status"
placeholder="Status (e.g. active / closed)"
value={form.status}
onChange={handleChange}
className="text-base"
/>
</div>
</div>
<DialogFooter className="pt-4 border-t">
<Button
variant="ghost"
onClick={() => setOpenModal(false)}
className="text-base"
>
Cancel
</Button>
<Button onClick={handleSubmit} className="px-8 text-base">
{editing ? "Save Changes" : "Create Career"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
-17
View File
@@ -1,17 +0,0 @@
import DashboardLayout from "@/components/layout/DashboardLayout";
export default function Dashboard() {
return (
<DashboardLayout>
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<div className="grid grid-cols-3 gap-6">
<div className="p-6 bg-white shadow rounded">Blogs</div>
<div className="p-6 bg-white shadow rounded">Departments</div>
<div className="p-6 bg-white shadow rounded">Users</div>
</div>
</DashboardLayout>
);
}
-9
View File
@@ -1,9 +0,0 @@
import DashboardLayout from "@/components/layout/DashboardLayout";
export default function Department() {
return (
<DashboardLayout>
<h1 className="text-xl font-semibold mb-4">Departments</h1>
</DashboardLayout>
);
}
+513
View File
@@ -0,0 +1,513 @@
import {useState, useEffect, useCallback} from "react";
import {AxiosError} from "axios";
import {BytescaleUploader} from "@/components/BytescaleUploader/BytescaleUploader";
import {
getDepartmentsApi,
createDepartmentApi,
updateDepartmentApi,
deleteDepartmentApi,
} from "@/api/department";
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 {Textarea} from "@/components/ui/textarea";
import {
Loader2,
RefreshCw,
Plus,
Pencil,
Trash,
Eye,
ChevronLeft,
ChevronRight,
} from "lucide-react";
interface Department {
departmentId: string;
name: string;
image?: string;
para1: string;
para2: string;
para3: string;
facilities: string;
services: string;
}
export default function DepartmentPage() {
const [departments, setDepartments] = useState<Department[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [openModal, setOpenModal] = useState(false);
const [editing, setEditing] = useState<Department | null>(null);
const [viewOpen, setViewOpen] = useState(false);
const [viewData, setViewData] = useState<Department | null>(null);
const [searchText, setSearchText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [form, setForm] = useState<Department>({
departmentId: "",
name: "",
image: "",
para1: "",
para2: "",
para3: "",
facilities: "",
services: "",
});
const fetchDepartments = useCallback(async () => {
setLoading(true);
setError("");
try {
const res = await getDepartmentsApi();
setDepartments(res?.data || []);
} catch (err) {
if (err instanceof AxiosError) {
setError(err.response?.data?.message || "Failed to load departments");
} else {
setError("Something went wrong");
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchDepartments();
}, [fetchDepartments]);
const filteredDepartments = departments.filter(
(dep) =>
dep.name.toLowerCase().includes(searchText.toLowerCase()) ||
dep.departmentId.toLowerCase().includes(searchText.toLowerCase()),
);
useEffect(() => {
setCurrentPage(1);
}, [searchText]);
const totalPages = Math.ceil(filteredDepartments.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredDepartments.slice(
indexOfFirstItem,
indexOfLastItem,
);
function handleChange(e: any) {
setForm({...form, [e.target.name]: e.target.value});
}
function truncate(text: string, limit = 60) {
if (!text) return "-";
return text.length > limit ? text.substring(0, limit) + "..." : text;
}
function openAdd() {
setEditing(null);
setForm({
departmentId: "",
name: "",
image: "",
para1: "",
para2: "",
para3: "",
facilities: "",
services: "",
});
setOpenModal(true);
}
function openEdit(dep: Department) {
setEditing(dep);
setForm(dep);
setOpenModal(true);
}
function openView(dep: Department) {
setViewData(dep);
setViewOpen(true);
}
async function handleSubmit() {
try {
if (editing) {
const {departmentId, ...updateData} = form;
await updateDepartmentApi(editing.departmentId, updateData);
} else {
await createDepartmentApi(form);
}
setOpenModal(false);
fetchDepartments();
} catch (error) {
console.error(error);
}
}
async function handleDelete(id: string) {
if (!confirm("Delete this department?")) return;
try {
await deleteDepartmentApi(id);
fetchDepartments();
} catch (error) {
console.error(error);
}
}
return (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<h1 className="text-3xl font-bold">Departments</h1>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search department..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[250px] text-base"
/>
<Button
variant="outline"
onClick={fetchDepartments}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={openAdd} className="text-base">
<Plus className="mr-2 h-5 w-5" />
Add Department
</Button>
</div>
</div>
{error && (
<div className="p-4 text-red-600 bg-red-50 border rounded-md text-base">
{error}
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-xl">Department List</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[900px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow>
<TableHead className="w-[100px] bg-background text-sm font-bold">
ID
</TableHead>
<TableHead className="w-[200px] bg-background text-sm font-bold">
Name
</TableHead>
<TableHead className="w-[250px] bg-background text-sm font-bold">
Para 1
</TableHead>
<TableHead className="w-[220px] bg-background text-sm font-bold">
Facilities
</TableHead>
<TableHead className="w-[220px] bg-background text-sm font-bold">
Services
</TableHead>
<TableHead className="w-[140px] bg-background text-right text-sm font-bold">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : currentItems.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground py-10 text-base"
>
No departments found
</TableCell>
</TableRow>
) : (
currentItems.map((dep) => (
<TableRow
key={dep.departmentId}
className="hover:bg-muted/50"
>
<TableCell className="font-mono text-xs">
{dep.departmentId}
</TableCell>
<TableCell>
<div
className="font-semibold text-base truncate"
title={dep.name}
>
{dep.name}
</div>
</TableCell>
<TableCell>
<div className="text-sm break-words whitespace-normal">
{truncate(dep.para1)}
</div>
</TableCell>
<TableCell>
<div className="text-sm break-words whitespace-normal">
{truncate(dep.facilities)}
</div>
</TableCell>
<TableCell>
<div className="text-sm break-words whitespace-normal">
{truncate(dep.services)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={() => openView(dep)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={() => openEdit(dep)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(dep.departmentId)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && filteredDepartments.length > 0 && (
<div className="flex items-center justify-between px-2 py-6 border-t">
<div className="text-base text-muted-foreground">
Showing{" "}
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
<span className="font-semibold">
{Math.min(indexOfLastItem, filteredDepartments.length)}
</span>{" "}
of{" "}
<span className="font-semibold">
{filteredDepartments.length}
</span>{" "}
departments
</div>
<div className="flex items-center gap-6">
<div className="text-base font-semibold">
Page {currentPage} of {totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
disabled={currentPage === 1}
>
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages || totalPages === 0}
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent className="w-full !max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editing ? "Edit Department" : "Add Department"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-semibold">Department Image</label>
<BytescaleUploader
value={form.image}
folderPath="/departments"
onChange={(url) => setForm({...form, image: url})}
/>
</div>
<Input
name="departmentId"
value={form.departmentId}
onChange={handleChange}
disabled={!!editing}
placeholder="Department ID"
/>
<Input
name="name"
value={form.name}
onChange={handleChange}
placeholder="Department Name"
/>
<Textarea
name="para1"
value={form.para1}
onChange={handleChange}
placeholder="Para1"
/>
<Textarea
name="para2"
value={form.para2}
onChange={handleChange}
placeholder="Para2"
/>
<Textarea
name="para3"
value={form.para3}
onChange={handleChange}
placeholder="Para3"
/>
<Textarea
name="facilities"
value={form.facilities}
onChange={handleChange}
placeholder="Facilities"
/>
<Textarea
name="services"
value={form.services}
onChange={handleChange}
placeholder="Services"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpenModal(false)}>
Cancel
</Button>
<Button onClick={handleSubmit}>
{editing ? "Save Changes" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
<DialogContent className="w-full !max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Department Details</DialogTitle>
</DialogHeader>
{viewData && (
<div className="space-y-4 text-sm">
<p>
<b>ID:</b> {viewData.departmentId}
</p>
<p>
<b>Name:</b> {viewData.name}
</p>
<p>
<b>Para1:</b>
<br />
{viewData.para1}
</p>
<p>
<b>Para2:</b>
<br />
{viewData.para2}
</p>
<p>
<b>Para3:</b>
<br />
{viewData.para3}
</p>
<p>
<b>Facilities:</b>
<br />
{viewData.facilities}
</p>
<p>
<b>Services:</b>
<br />
{viewData.services}
</p>
</div>
)}
<DialogFooter>
<Button onClick={() => setViewOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+630
View File
@@ -0,0 +1,630 @@
import { useState, useEffect, useCallback } from "react";
import { AxiosError } from "axios";
import { BytescaleUploader } from "@/components/BytescaleUploader/BytescaleUploader";
import {
getDoctorsApi,
createDoctorApi,
updateDoctorApi,
deleteDoctorApi,
getDoctorTimingApi,
} from "@/api/doctor";
import { getDepartmentsApi } from "@/api/department";
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 { Badge } from "@/components/ui/badge";
import {
Loader2,
RefreshCw,
Plus,
Pencil,
Trash,
ChevronLeft,
ChevronRight,
User,
} from "lucide-react";
interface Department {
departmentId: string;
name: string;
}
const DAYS = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
"additional",
];
export default function DoctorPage() {
const [doctors, setDoctors] = useState<any[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [openModal, setOpenModal] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [searchText, setSearchText] = useState("");
const [filterDepartment, setFilterDepartment] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [form, setForm] = useState<any>({
doctorId: "",
name: "",
image: "",
designation: "",
workingStatus: "",
qualification: "",
departments: [],
});
const fetchAll = useCallback(async () => {
setLoading(true);
setError("");
try {
const [docRes, depRes] = await Promise.all([
getDoctorsApi(),
getDepartmentsApi(),
]);
setDoctors(docRes?.data || []);
setDepartments(depRes?.data || []);
} catch (err) {
if (err instanceof AxiosError) {
setError(err.response?.data?.message || "Failed to load data");
} else {
setError("Something went wrong");
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAll();
}, [fetchAll]);
const filteredDoctors = doctors.filter((doc) => {
const matchesSearch =
doc.name.toLowerCase().includes(searchText.toLowerCase()) ||
doc.doctorId.toLowerCase().includes(searchText.toLowerCase());
const matchesDepartment = filterDepartment
? doc.departments?.some((d: any) => d.departmentId === filterDepartment)
: true;
return matchesSearch && matchesDepartment;
});
useEffect(() => {
setCurrentPage(1);
}, [searchText, filterDepartment]);
const totalPages = Math.ceil(filteredDoctors.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredDoctors.slice(indexOfFirstItem, indexOfLastItem);
function handleChange(e: any) {
setForm({ ...form, [e.target.name]: e.target.value });
}
function handleDepartmentToggle(depId: string) {
const exists = form.departments.find((d: any) => d.departmentId === depId);
if (exists) {
setForm({
...form,
departments: form.departments.filter(
(d: any) => d.departmentId !== depId,
),
});
} else {
setForm({
...form,
departments: [...form.departments, { departmentId: depId, timing: {} }],
});
}
}
function handleTimingChange(depId: string, day: string, value: string) {
setForm({
...form,
departments: form.departments.map((d: any) =>
d.departmentId === depId
? { ...d, timing: { ...d.timing, [day]: value } }
: d,
),
});
}
function openAdd() {
setEditing(null);
setForm({
doctorId: "",
name: "",
image: "",
designation: "",
workingStatus: "",
qualification: "",
departments: [],
});
setOpenModal(true);
}
async function openEdit(doc: any) {
setEditing(doc);
try {
const timingRes = await getDoctorTimingApi(doc.doctorId);
const timingData = timingRes?.data?.departments || [];
setForm({
doctorId: doc.doctorId,
name: doc.name,
image: doc.image || "",
designation: doc.designation,
workingStatus: doc.workingStatus,
qualification: doc.qualification,
departments: timingData.map((d: any) => ({
departmentId: d.departmentId,
timing: d.timing || {},
})),
});
setOpenModal(true);
} catch (err) {
console.error(err);
}
}
async function handleSubmit() {
try {
if (editing) {
await updateDoctorApi(editing.doctorId, form);
} else {
await createDoctorApi(form);
}
setOpenModal(false);
fetchAll();
} catch (error) {
console.error(error);
}
}
async function handleDelete(id: string) {
if (!confirm("Delete this doctor?")) return;
try {
await deleteDoctorApi(id);
fetchAll();
} catch (error) {
console.error(error);
}
}
return (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<h1 className="text-3xl font-bold">Doctors</h1>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search doctor..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[250px] text-base"
/>
<select
value={filterDepartment}
onChange={(e) => setFilterDepartment(e.target.value)}
className="flex h-10 w-[220px] rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="">All Departments</option>
{departments.map((dep) => (
<option key={dep.departmentId} value={dep.departmentId}>
{dep.name}
</option>
))}
</select>
<Button
variant="outline"
onClick={fetchAll}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={openAdd} className="text-base">
<Plus className="mr-2 h-5 w-5" />
Add Doctor
</Button>
</div>
</div>
{error && (
<div className="p-4 text-red-600 bg-red-50 border rounded-md text-base">
{error}
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-xl">Doctor List</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[900px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow>
<TableHead className="w-[100px] bg-background text-sm font-bold">
ID
</TableHead>
<TableHead className="w-[200px] bg-background text-sm font-bold">
Name
</TableHead>
<TableHead className="w-[180px] bg-background text-sm font-bold">
Designation
</TableHead>
<TableHead className="w-[180px] bg-background text-sm font-bold">
Qualification
</TableHead>
<TableHead className="w-[220px] bg-background text-sm font-bold">
Departments
</TableHead>
<TableHead className="w-[120px] bg-background text-right text-sm font-bold">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : currentItems.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground py-10 text-base"
>
No doctors found
</TableCell>
</TableRow>
) : (
currentItems.map((doc) => (
<TableRow key={doc.doctorId} className="hover:bg-muted/50">
<TableCell className="truncate font-mono text-xs">
{doc.doctorId}
</TableCell>
<TableCell>
<div
className="font-semibold text-base truncate"
title={doc.name}
>
{doc.name}
</div>
<div className="text-xs text-muted-foreground truncate italic">
{doc.workingStatus}
</div>
</TableCell>
<TableCell>
<div
className="truncate text-sm"
title={doc.designation}
>
{doc.designation || "-"}
</div>
</TableCell>
<TableCell>
<div
className="truncate text-sm"
title={doc.qualification}
>
{doc.qualification || "-"}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{doc.departments?.map((d: any) => (
<Badge
key={d.departmentId}
variant="secondary"
className="text-xs px-2 h-5 leading-none"
>
{d.departmentName}
</Badge>
))}
{doc.departments?.length === 0 && (
<span className="text-muted-foreground">-</span>
)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={() => openEdit(doc)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(doc.doctorId)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && filteredDoctors.length > 0 && (
<div className="flex items-center justify-between px-2 py-6 border-t">
<div className="text-base text-muted-foreground">
Showing{" "}
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
<span className="font-semibold">
{Math.min(indexOfLastItem, filteredDoctors.length)}
</span>{" "}
of{" "}
<span className="font-semibold">{filteredDoctors.length}</span>{" "}
doctors
</div>
<div className="flex items-center gap-6">
<div className="text-base font-semibold">
Page {currentPage} of {totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
disabled={currentPage === 1}
>
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages || totalPages === 0}
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent className="w-full !max-w-5xl h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="p-6 border-b bg-background z-10">
<DialogTitle className="text-2xl">
{editing ? "Edit Doctor" : "Add Doctor"}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-6">
<h3 className="font-bold text-base border-b pb-2">
Basic Information
</h3>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-semibold">
Doctor Photo
</label>
<BytescaleUploader
value={form.image}
folderPath="/doctors"
onChange={(url) => setForm({ ...form, image: url })}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold">Doctor ID</label>
<Input
name="doctorId"
placeholder="GG-DOC-001"
value={form.doctorId}
onChange={handleChange}
disabled={!!editing}
className="text-base"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold">Full Name</label>
<Input
name="name"
placeholder="Dr. John Doe"
value={form.name}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold">Designation</label>
<Input
name="designation"
placeholder="Senior Consultant"
value={form.designation}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold">
Working Status
</label>
<Input
name="workingStatus"
placeholder="Active / On Call"
value={form.workingStatus}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold">
Qualification
</label>
<Input
name="qualification"
placeholder="MBBS, MD"
value={form.qualification}
onChange={handleChange}
className="text-base"
/>
</div>
</div>
<div className="p-5 border rounded-md bg-muted/20">
<p className="text-base font-bold mb-4">Assign Departments</p>
<div className="grid grid-cols-2 gap-3">
{departments.map((dep) => {
const isSelected = form.departments.some(
(d: any) => d.departmentId === dep.departmentId,
);
return (
<Button
key={dep.departmentId}
type="button"
variant={isSelected ? "default" : "outline"}
size="sm"
className="justify-start text-sm min-h-11 whitespace-normal break-words text-left py-2"
onClick={() =>
handleDepartmentToggle(dep.departmentId)
}
>
{dep.name}
</Button>
);
})}
</div>
</div>
</div>
<div className="space-y-6">
<h3 className="font-bold text-base border-b pb-2">
Working Hours / Timing
</h3>
{form.departments.length === 0 ? (
<div className="text-base text-muted-foreground italic py-24 text-center border-2 border-dashed rounded-lg">
Select a department to configure timing slots
</div>
) : (
<div className="space-y-8">
{form.departments.map((dep: any) => {
const depName = departments.find(
(d) => d.departmentId === dep.departmentId,
)?.name;
return (
<div
key={dep.departmentId}
className="space-y-4 p-5 border rounded-lg bg-background shadow-sm"
>
<div className="flex items-center justify-between">
<p className="font-bold text-base text-primary">
{depName}
</p>
<Badge variant="outline" className="text-xs">
Timing Slot
</Badge>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3">
{DAYS.map((day) => (
<div key={day} className="space-y-1">
<label className="text-xs uppercase font-bold text-muted-foreground">
{day}
</label>
<Input
className="h-9 text-sm"
placeholder="e.g. 09:00 AM - 01:00 PM"
value={dep.timing?.[day] || ""}
onChange={(e) =>
handleTimingChange(
dep.departmentId,
day,
e.target.value,
)
}
/>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
<DialogFooter className="p-6 border-t bg-background z-10 mt-0">
<Button
variant="ghost"
onClick={() => setOpenModal(false)}
className="text-base"
>
Cancel
</Button>
<Button onClick={handleSubmit} className="px-10 text-base">
{editing ? "Save Changes" : "Create Doctor Profile"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+384
View File
@@ -0,0 +1,384 @@
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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Loader2,
Trash,
RefreshCw,
Download,
ChevronLeft,
ChevronRight,
Eye,
User,
} from "lucide-react";
export default function CandidatePage() {
const [candidates, setCandidates] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState("");
const [filterCareer, setFilterCareer] = useState("");
const [viewOpen, setViewOpen] = useState(false);
const [viewData, setViewData] = useState<any>(null);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
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;
});
useEffect(() => {
setCurrentPage(1);
}, [searchText, filterCareer]);
const totalPages = Math.ceil(filteredCandidates.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredCandidates.slice(
indexOfFirstItem,
indexOfLastItem,
);
function openView(item: any) {
setViewData(item);
setViewOpen(true);
}
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,
AppliedDate: new Date(item.createdAt).toLocaleDateString(),
}));
exportToExcel(exportData, "candidates");
};
return (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<h1 className="text-3xl font-bold">Candidates</h1>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search candidate..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[250px] text-base"
/>
<Input
placeholder="Filter by Career"
value={filterCareer}
onChange={(e) => setFilterCareer(e.target.value)}
className="w-[200px] text-base"
/>
<Button
variant="outline"
onClick={fetchAll}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={handleExport} className="text-base">
<Download className="mr-2 h-5 w-5" />
Export
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-xl">Application List</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[1100px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow>
<TableHead className="w-[60px] bg-background font-bold text-sm">
ID
</TableHead>
<TableHead className="w-[220px] bg-background font-bold text-sm">
Full Name
</TableHead>
<TableHead className="w-[180px] bg-background font-bold text-sm">
Career & Post
</TableHead>
<TableHead className="w-[150px] bg-background font-bold text-sm">
Contact
</TableHead>
<TableHead className="w-[140px] bg-background font-bold text-sm">
Applied On
</TableHead>
<TableHead className="w-[250px] bg-background font-bold text-sm">
Cover Letter
</TableHead>
<TableHead className="w-[120px] bg-background font-bold text-right text-sm">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : currentItems.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground py-10 text-base"
>
No candidates found
</TableCell>
</TableRow>
) : (
currentItems.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs">
{item.id}
</TableCell>
<TableCell>
<div className="font-semibold text-base truncate">
{item.fullName}
</div>
<div className="text-xs text-muted-foreground truncate">
{item.email}
</div>
</TableCell>
<TableCell>
<div className="text-sm font-medium">
{item.career?.post || "-"}
</div>
<div className="text-[10px] text-muted-foreground truncate">
{item.career?.designation}
</div>
</TableCell>
<TableCell className="text-sm">{item.mobile}</TableCell>
<TableCell className="text-sm">
{new Date(item.createdAt).toLocaleDateString()}
</TableCell>
<TableCell>
<div className="text-sm line-clamp-2 text-muted-foreground italic">
{item.coverLetter || "No cover letter provided."}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={() => openView(item)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(item.id)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && filteredCandidates.length > 0 && (
<div className="flex items-center justify-between px-2 py-6 border-t">
<div className="text-base text-muted-foreground">
Showing{" "}
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
<span className="font-semibold">
{Math.min(indexOfLastItem, filteredCandidates.length)}
</span>{" "}
of{" "}
<span className="font-semibold">
{filteredCandidates.length}
</span>
</div>
<div className="flex items-center gap-6">
<div className="text-base font-semibold">
Page {currentPage} of {totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
disabled={currentPage === 1}
>
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages || totalPages === 0}
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
<DialogContent className="w-full !max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl border-b pb-2 flex items-center gap-2">
<User className="h-6 w-6" /> Candidate Details
</DialogTitle>
</DialogHeader>
{viewData && (
<div className="space-y-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Personal Information
</p>
<p className="text-lg font-bold text-primary">
{viewData.fullName}
</p>
<p className="text-sm font-medium">{viewData.email}</p>
<p className="text-sm">{viewData.mobile}</p>
</div>
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Applied For
</p>
<p className="text-base font-semibold">
{viewData.career?.post || "General Application"}
</p>
<p className="text-sm text-muted-foreground">
{viewData.career?.designation}
</p>
</div>
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Application Date
</p>
<p className="text-sm">
{new Date(viewData.createdAt).toLocaleString()}
</p>
</div>
</div>
<div className="space-y-4">
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Subject
</p>
<p className="text-sm font-semibold">
{viewData.subject || "N/A"}
</p>
</div>
<div className="p-4 bg-muted/30 rounded-lg">
<p className="text-xs uppercase font-bold text-muted-foreground mb-2">
Cover Letter / Message
</p>
<p className="text-sm leading-relaxed whitespace-pre-wrap italic">
{viewData.coverLetter || "No cover letter provided."}
</p>
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<Button
onClick={() => setViewOpen(false)}
className="w-full md:w-auto"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+266
View File
@@ -0,0 +1,266 @@
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="ACADEMICS">ACADEMICS</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>
);
}
+355
View File
@@ -0,0 +1,355 @@
import { useState, useEffect, useCallback } from "react";
import { getInquiriesApi, deleteInquiryApi } from "@/api/inquiry";
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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Loader2,
Trash,
RefreshCw,
Download,
ChevronLeft,
ChevronRight,
Eye,
Mail,
} from "lucide-react";
export default function InquiryPage() {
const [inquiries, setInquiries] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState("");
const [viewOpen, setViewOpen] = useState(false);
const [viewData, setViewData] = useState<any>(null);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const fetchAll = useCallback(async () => {
setLoading(true);
try {
const res = await getInquiriesApi();
setInquiries(res?.data || []);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAll();
}, [fetchAll]);
const filteredInquiries = inquiries.filter((item) => {
return (
item.fullName?.toLowerCase().includes(searchText.toLowerCase()) ||
item.number?.includes(searchText) ||
item.emailId?.toLowerCase().includes(searchText.toLowerCase()) ||
item.subject?.toLowerCase().includes(searchText.toLowerCase())
);
});
useEffect(() => {
setCurrentPage(1);
}, [searchText]);
const totalPages = Math.ceil(filteredInquiries.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredInquiries.slice(
indexOfFirstItem,
indexOfLastItem,
);
function openView(item: any) {
setViewData(item);
setViewOpen(true);
}
async function handleDelete(id: number) {
if (!confirm("Delete inquiry?")) return;
await deleteInquiryApi(id);
fetchAll();
}
const handleExport = () => {
const exportData = filteredInquiries.map((item) => ({
ID: item.id,
Name: item.fullName,
Phone: item.number,
Email: item.emailId,
Subject: item.subject,
Message: item.message,
Date: new Date(item.createdAt).toLocaleDateString(),
}));
exportToExcel(exportData, "inquiries");
};
return (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<h1 className="text-3xl font-bold">Inquiries</h1>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search name / phone / subject..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[280px] text-base"
/>
<Button
variant="outline"
onClick={fetchAll}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={handleExport} className="text-base">
<Download className="mr-2 h-5 w-5" />
Export
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-xl">Customer Inquiries</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[1000px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow>
<TableHead className="w-[60px] bg-background font-bold text-sm">
ID
</TableHead>
<TableHead className="w-[220px] bg-background font-bold text-sm">
Customer Details
</TableHead>
<TableHead className="w-[200px] bg-background font-bold text-sm">
Subject
</TableHead>
<TableHead className="w-[150px] bg-background font-bold text-sm">
Date
</TableHead>
<TableHead className="w-[280px] bg-background font-bold text-sm">
Message Snippet
</TableHead>
<TableHead className="w-[120px] bg-background font-bold text-right text-sm">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : currentItems.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground py-10 text-base"
>
No inquiries found
</TableCell>
</TableRow>
) : (
currentItems.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs">
{item.id}
</TableCell>
<TableCell>
<div className="font-semibold text-base truncate">
{item.fullName}
</div>
<div className="text-xs text-muted-foreground truncate">
{item.emailId}
</div>
<div className="text-[11px] font-medium">
{item.number}
</div>
</TableCell>
<TableCell>
<div className="text-sm font-medium line-clamp-2">
{item.subject || "-"}
</div>
</TableCell>
<TableCell className="text-sm">
{new Date(item.createdAt).toLocaleDateString()}
</TableCell>
<TableCell>
<div className="text-sm line-clamp-2 text-muted-foreground italic">
{item.message || "-"}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={() => openView(item)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(item.id)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && filteredInquiries.length > 0 && (
<div className="flex items-center justify-between px-2 py-6 border-t">
<div className="text-base text-muted-foreground">
Showing{" "}
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
<span className="font-semibold">
{Math.min(indexOfLastItem, filteredInquiries.length)}
</span>{" "}
of{" "}
<span className="font-semibold">
{filteredInquiries.length}
</span>
</div>
<div className="flex items-center gap-6">
<div className="text-base font-semibold">
Page {currentPage} of {totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
disabled={currentPage === 1}
>
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages || totalPages === 0}
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
<DialogContent className="w-full !max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl border-b pb-2 flex items-center gap-2">
<Mail className="h-6 w-6" /> Inquiry Details
</DialogTitle>
</DialogHeader>
{viewData && (
<div className="space-y-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Customer Information
</p>
<p className="text-lg font-bold text-primary">
{viewData.fullName}
</p>
<p className="text-sm font-medium">{viewData.emailId}</p>
<p className="text-sm">{viewData.number}</p>
</div>
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Received Date
</p>
<p className="text-sm">
{new Date(viewData.createdAt).toLocaleString()}
</p>
</div>
</div>
<div className="space-y-4">
<div>
<p className="text-xs uppercase font-bold text-muted-foreground">
Subject
</p>
<p className="text-base font-semibold">
{viewData.subject || "No Subject"}
</p>
</div>
<div className="p-4 bg-muted/30 rounded-lg border">
<p className="text-xs uppercase font-bold text-muted-foreground mb-2">
Message
</p>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{viewData.message || "No message content."}
</p>
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<Button
onClick={() => setViewOpen(false)}
className="w-full md:w-auto"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+573
View File
@@ -0,0 +1,573 @@
import { useState, useEffect, useCallback } from "react";
import { BytescaleUploader } from "@/components/BytescaleUploader/BytescaleUploader";
import {
getNewsApi,
createNewsApi,
updateNewsApi,
deleteNewsApi,
} from "@/api/newsMedia";
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 { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Loader2,
Plus,
Pencil,
Trash,
RefreshCw,
Eye,
ChevronLeft,
ChevronRight,
Newspaper,
ImageIcon,
X,
} from "lucide-react";
export default function NewsPage() {
const [news, setNews] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [totalItems, setTotalItems] = useState(0);
const [searchText, setSearchText] = useState("");
const [openModal, setOpenModal] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [viewOpen, setViewOpen] = useState(false);
const [viewData, setViewData] = useState<any>(null);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [form, setForm] = useState({
headline: "",
content: "",
imageUrls: [] as string[],
firstPara: "",
secondPara: "",
date: "",
author: "",
});
const fetchAll = useCallback(async () => {
setLoading(true);
try {
const res = await getNewsApi(currentPage, itemsPerPage);
setNews(res?.data || []);
setTotalItems(res?.meta?.total || 0);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [currentPage, itemsPerPage]);
useEffect(() => {
fetchAll();
}, [fetchAll]);
const filteredNews = news.filter(
(item) =>
item.Headline?.toLowerCase().includes(searchText.toLowerCase()) ||
item.Author?.toLowerCase().includes(searchText.toLowerCase()),
);
const totalPages = Math.ceil(totalItems / itemsPerPage);
function handleChange(e: any) {
setForm({ ...form, [e.target.name]: e.target.value });
}
function removeImageUrl(index: number) {
setForm((prev) => ({
...prev,
imageUrls: prev.imageUrls.filter((_, i) => i !== index),
}));
}
function openAdd() {
setEditing(null);
setForm({
headline: "",
content: "",
imageUrls: [],
firstPara: "",
secondPara: "",
date: "",
author: "",
});
setOpenModal(true);
}
function openEdit(item: any) {
setEditing(item);
setForm({
headline: item.Headline || "",
content: item.Content || "",
imageUrls: item.Images ? item.Images.map((img: any) => img.image) : [],
firstPara: item.FirstPara || "",
secondPara: item.SecondPara || "",
date: item.Date ? item.Date.split("T")[0] : "",
author: item.Author || "",
});
setOpenModal(true);
}
function openView(item: any) {
setViewData(item);
setViewOpen(true);
}
async function handleSubmit() {
try {
if (editing) {
await updateNewsApi(editing.Id, form);
} else {
await createNewsApi(form);
}
setOpenModal(false);
fetchAll();
} catch (err) {
console.error(err);
}
}
async function handleDelete(id: number) {
if (!confirm("Delete news?")) return;
await deleteNewsApi(id);
fetchAll();
}
return (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<h1 className="text-3xl font-bold font-sans tracking-tight">
News Media
</h1>
<div className="flex flex-wrap gap-3 items-center">
<Input
placeholder="Filter headline..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[250px] text-base"
/>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm focus:ring-2 focus:ring-primary"
>
<option value={5}>5 / page</option>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
</select>
<Button
variant="outline"
onClick={fetchAll}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={openAdd} className="text-base">
<Plus className="mr-2 h-5 w-5" />
Add News
</Button>
</div>
</div>
<Card className="shadow-sm">
<CardHeader>
<CardTitle className="text-xl">News Archives</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[1000px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow>
<TableHead className="w-[80px] bg-background font-bold">
ID
</TableHead>
<TableHead className="w-[100px] bg-background font-bold">
Cover
</TableHead>
<TableHead className="w-[280px] bg-background font-bold">
Headline
</TableHead>
<TableHead className="w-[150px] bg-background font-bold">
Author
</TableHead>
<TableHead className="w-[140px] bg-background font-bold">
Date
</TableHead>
<TableHead className="w-[250px] bg-background font-bold">
Content Preview
</TableHead>
<TableHead className="w-[150px] bg-background font-bold text-right">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
</TableCell>
</TableRow>
) : filteredNews.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground py-10 text-base"
>
No news articles found
</TableCell>
</TableRow>
) : (
filteredNews.map((item) => (
<TableRow key={item.Id} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs">
{item.Id}
</TableCell>
<TableCell>
{item.Images?.[0] ? (
<img
src={item.Images[0].image}
className="w-10 h-10 object-cover rounded border"
alt="cover"
/>
) : (
<div className="w-10 h-10 bg-muted flex items-center justify-center rounded">
<ImageIcon className="w-4 h-4 text-muted-foreground" />
</div>
)}
</TableCell>
<TableCell>
<div
className="font-semibold text-base line-clamp-2"
title={item.Headline}
>
{item.Headline}
</div>
</TableCell>
<TableCell className="text-sm font-medium">
{item.Author || "-"}
</TableCell>
<TableCell className="text-sm">
{item.Date
? new Date(item.Date).toLocaleDateString()
: "-"}
</TableCell>
<TableCell>
<div className="text-sm line-clamp-2 text-muted-foreground">
{item.Content}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={() => openView(item)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={() => openEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(Number(item.Id))}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && totalItems > 0 && (
<div className="flex items-center justify-between px-2 py-4 border-t">
<div className="text-sm text-muted-foreground">
Total <span className="font-bold">{totalItems}</span> articles
(Page <span className="font-bold">{currentPage}</span> of{" "}
{totalPages})
</div>
<div className="flex items-center gap-6">
<div className="text-base font-semibold">
Page {currentPage} of {totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
disabled={currentPage === 1}
>
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages || totalPages === 0}
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent className="w-full !max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
{editing ? "Edit News Article" : "Add New News Article"}
</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-6">
<div className="lg:col-span-2 space-y-6">
<h3 className="font-bold text-base border-b pb-2 text-primary">
Article Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1 col-span-2">
<label className="text-sm font-semibold">Headline</label>
<Input
name="headline"
value={form.headline}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold">Author</label>
<Input
name="author"
value={form.author}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold">Publish Date</label>
<Input
type="date"
name="date"
value={form.date}
onChange={handleChange}
className="text-base"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold">Intro Paragraph</label>
<Textarea
name="firstPara"
value={form.firstPara}
onChange={handleChange}
className="min-h-[100px] text-base"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold">
Full Story Content
</label>
<Textarea
name="content"
value={form.content}
onChange={handleChange}
className="min-h-[200px] text-base"
/>
</div>
</div>
{/* Image Management Sidebar */}
<div className="space-y-6 border-l pl-6">
<h3 className="font-bold text-base border-b pb-2 text-primary flex items-center gap-2">
<ImageIcon className="w-4 h-4" /> Gallery Management
</h3>
<div className="space-y-4">
<label className="text-sm font-semibold">Upload Images</label>
<BytescaleUploader
value=""
folderPath="/news"
onChange={(url) => {
if (url) {
setForm((prev) => ({
...prev,
imageUrls: [...prev.imageUrls, url],
}));
}
}}
/>
<div className="grid grid-cols-2 gap-2 max-h-[400px] overflow-y-auto pr-2 mt-4">
{form.imageUrls.map((url, index) => (
<div
key={index}
className="relative group border rounded-lg overflow-hidden h-24 bg-muted"
>
<img
src={url}
alt="upload"
className="w-full h-full object-cover"
/>
<button
type="button"
onClick={() => removeImageUrl(index)}
className="absolute top-1 right-1 bg-destructive text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
{form.imageUrls.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-10 border-2 border-dashed rounded-lg">
No images uploaded yet.
</p>
)}
</div>
</div>
</div>
<DialogFooter className="mt-10 pt-6 border-t">
<Button
variant="ghost"
onClick={() => setOpenModal(false)}
className="text-base"
>
Cancel
</Button>
<Button
onClick={handleSubmit}
className="px-10 text-base bg-primary text-white"
>
{editing ? "Save Changes" : "Publish Now"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
<DialogContent className="w-full !max-w-4xl max-h-[85vh] overflow-y-auto p-6">
<DialogHeader>
<DialogTitle className="text-2xl border-b pb-2 flex items-center gap-2">
<Newspaper className="h-6 w-6 text-primary" /> News Preview
</DialogTitle>
</DialogHeader>
{viewData && (
<div className="space-y-6 py-4">
<div className="space-y-2">
<h2 className="text-2xl font-bold leading-tight">
{viewData.Headline}
</h2>
<div className="flex gap-4 text-sm text-muted-foreground font-medium italic">
<span>By: {viewData.Author || "Anonymous"}</span>
<span></span>
<span>
{viewData.Date
? new Date(viewData.Date).toLocaleDateString("en-IN", {
dateStyle: "long",
})
: "No Date"}
</span>
</div>
</div>
<div className="grid grid-cols-3 md:grid-cols-4 gap-2">
{viewData.Images?.map((img: any, i: number) => (
<img
key={i}
src={img.image}
className="w-full h-24 object-cover rounded-md border"
alt="gallery"
/>
))}
</div>
<div className="space-y-5 leading-relaxed text-base">
<div className="bg-muted/30 p-4 rounded-lg border-l-4 border-primary">
<p className="whitespace-pre-line">{viewData.FirstPara}</p>
</div>
<div className="space-y-4 px-1">
<p className="whitespace-pre-line">{viewData.SecondPara}</p>
<hr />
<p className="whitespace-pre-line text-muted-foreground">
{viewData.Content}
</p>
</div>
</div>
</div>
)}
<DialogFooter>
<Button
onClick={() => setViewOpen(false)}
className="w-full md:w-auto"
>
Close Preview
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+22
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`);
};
-4
View File
@@ -1,8 +1,4 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
};
+6
View File
@@ -0,0 +1,6 @@
{
"name": "gg-backend-new",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}