Compare commits

..

25 Commits

Author SHA1 Message Date
rishalkv eb68d0acc4 Merge pull request 'fix:added validations for api' (#35) from fix/doc-validations into feat/doc-seo-content-enhacement
Reviewed-on: #35
2026-05-21 06:06:30 +00:00
rishalkv 667e15513c fix:added validations for api 2026-05-21 11:20:09 +05:30
rishalkv 2a786ef118 fix:unwanted query exec on update 2026-05-20 10:43:05 +05:30
rishalkv da6587c83d fix:editing doctor dept 2026-05-20 10:28:46 +05:30
rishalkv 5fea2a306d feat:add seo and more about doctores 2026-05-20 10:15:53 +05:30
kailasdevdas 08b9c2647e Merge pull request 'chore: add toast show validation errors' (#32) from fix/health-checkup-validation into dev
Reviewed-on: #32
2026-05-18 11:21:57 +00:00
Kailasdevdas 98194283df chore: add toast show validation errors 2026-05-18 16:41:38 +05:30
kailasdevdas 1320ce6fe6 Merge pull request 'chore: show required image dimension' (#31) from feat/healthcheckup-crud into dev
Reviewed-on: #31
2026-05-18 07:47:06 +00:00
Kailasdevdas 3dbbb2e77e chore: show required image dimension 2026-05-18 13:15:00 +05:30
kailasdevdas 5b1d626661 Merge pull request 'feat: health checkup CRUD apis' (#30) from feat/healthcheckup-crud into dev
Reviewed-on: #30
2026-05-18 06:26:27 +00:00
Kailasdevdas 098fe12fd7 feat: add image upload for health package 2026-05-18 11:55:55 +05:30
Kailasdevdas 852a25269a feat: add toast 2026-05-18 10:58:24 +05:30
Kailasdevdas d92e0538bd fix: make category slug optional 2026-05-18 10:58:14 +05:30
Kailasdevdas 8d60afdc49 feat: health checkup page 2026-05-15 17:58:25 +05:30
Kailasdevdas 9bc0bf406a feat: health checkup CRUD apis 2026-05-15 17:46:52 +05:30
kailasdevdas 3140d72e28 Merge pull request 'fix:blog editor image upload' (#28) from fix/blog-text-editor-image-uploader into dev
Reviewed-on: #28
2026-05-13 11:49:49 +00:00
rishalkv 6117805467 fix:blog editor image upload 2026-05-13 17:16:25 +05:30
kailasdevdas b002c053ae Merge pull request 'fix: doctor toggle logic' (#27) from feat/appointment-date-filter into dev
Reviewed-on: #27
2026-05-13 09:12:35 +00:00
kailasdevdas e6044518d2 Merge pull request 'feat: update date format in mail' (#26) from feat/email-date-format into dev
Reviewed-on: #26
2026-05-13 09:03:56 +00:00
Kailasdevdas 6889137164 feat: add appointment date range filter 2026-05-13 14:20:51 +05:30
Kailasdevdas 988fbd28f1 fix: doctor toggle logic 2026-05-13 14:19:42 +05:30
Kailasdevdas fa2b02ad23 feat: update date format in mail 2026-05-13 11:57:33 +05:30
kailasdevdas 199797fdf4 Merge pull request 'feat:sorting according to the prio of dept' (#24) from feat/department-internal-sort into dev
Reviewed-on: #24
2026-05-11 11:37:53 +00:00
rishalkv 5efe049fbd feat:sorting according to the prio of dept 2026-05-11 16:27:20 +05:30
kailasdevdas a008f09923 Merge pull request 'feat: implement sorting and visibility changes' (#23) from feat/inactive-field into dev
Reviewed-on: #23
2026-05-11 05:35:39 +00:00
30 changed files with 3513 additions and 100 deletions
@@ -0,0 +1,63 @@
-- CreateTable
CREATE TABLE "HealthCheckCategory" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 1000,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HealthCheckCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HealthPackage" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"price" DECIMAL(10,2),
"discountedPrice" DECIMAL(10,2),
"inclusions" JSONB NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 1000,
"categoryId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HealthPackage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HealthPackageInquiry" (
"id" SERIAL NOT NULL,
"fullName" TEXT NOT NULL,
"mobileNumber" TEXT NOT NULL,
"email" TEXT,
"preferredDate" TIMESTAMP(3),
"message" TEXT,
"packageId" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HealthPackageInquiry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "HealthCheckCategory_name_key" ON "HealthCheckCategory"("name");
-- CreateIndex
CREATE UNIQUE INDEX "HealthCheckCategory_slug_key" ON "HealthCheckCategory"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "HealthPackage_slug_key" ON "HealthPackage"("slug");
-- AddForeignKey
ALTER TABLE "HealthPackage" ADD CONSTRAINT "HealthPackage_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "HealthCheckCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HealthPackageInquiry" ADD CONSTRAINT "HealthPackageInquiry_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "HealthPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "HealthPackageInquiry" ADD COLUMN "age" INTEGER,
ADD COLUMN "gender" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "HealthPackage" ALTER COLUMN "inclusions" SET DEFAULT '{}';
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "HealthCheckCategory" ALTER COLUMN "slug" DROP NOT NULL;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "HealthPackage" ADD COLUMN "image" TEXT;
@@ -0,0 +1,50 @@
/*
Warnings:
- A unique constraint covering the columns `[seoId]` on the table `Doctor` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "professionalSummary" TEXT,
ADD COLUMN "seoId" INTEGER;
-- CreateTable
CREATE TABLE "DoctorSpecialization" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"doctorId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DoctorSpecialization_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Seo" (
"id" SERIAL NOT NULL,
"seoTitle" TEXT,
"metaDescription" TEXT,
"focusKeyphrase" TEXT,
"slug" TEXT,
"tags" TEXT[],
"ogTitle" TEXT,
"ogDescription" TEXT,
"ogImage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Seo_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Seo_slug_key" ON "Seo"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Doctor_seoId_key" ON "Doctor"("seoId");
-- AddForeignKey
ALTER TABLE "Doctor" ADD CONSTRAINT "Doctor_seoId_fkey" FOREIGN KEY ("seoId") REFERENCES "Seo"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DoctorSpecialization" ADD CONSTRAINT "DoctorSpecialization_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "Doctor"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "experience" INTEGER;
+90 -1
View File
@@ -23,11 +23,15 @@ model Doctor {
name String name String
image String? image String?
designation String? designation String?
experience Int?
workingStatus String? workingStatus String?
qualification String? qualification String?
isActive Boolean @default(true) isActive Boolean @default(true)
globalSortOrder Int @default(1000) globalSortOrder Int @default(1000)
specializations DoctorSpecialization[]
professionalSummary String? @db.Text
seoId Int? @unique
seo Seo? @relation(fields: [seoId], references: [id])
departments DoctorDepartment[] departments DoctorDepartment[]
appointments Appointment[] appointments Appointment[]
@@ -220,3 +224,88 @@ model NewsImage {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
model HealthCheckCategory {
id Int @id @default(autoincrement())
name String @unique
slug String? @unique
description String?
isActive Boolean @default(true)
sortOrder Int @default(1000)
packages HealthPackage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model HealthPackage {
id Int @id @default(autoincrement())
name String
slug String @unique
description String?
price Decimal? @db.Decimal(10, 2)
image String?
discountedPrice Decimal? @db.Decimal(10, 2)
inclusions Json @default("{}")
isActive Boolean @default(true)
isFeatured Boolean @default(false)
sortOrder Int @default(1000)
categoryId Int
category HealthCheckCategory @relation(fields: [categoryId], references: [id])
inquiries HealthPackageInquiry[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model HealthPackageInquiry {
id Int @id @default(autoincrement())
fullName String
mobileNumber String
email String?
age Int?
gender String?
preferredDate DateTime?
message String?
packageId Int
healthPackage HealthPackage @relation(fields: [packageId], references: [id])
status String @default("pending")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model DoctorSpecialization {
id Int @id @default(autoincrement())
name String
description String? @db.Text
doctorId Int
doctor Doctor @relation(fields: [doctorId],references: [id],onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Seo {
id Int @id @default(autoincrement())
doctor Doctor?
seoTitle String?
metaDescription String? @db.Text
focusKeyphrase String?
slug String? @unique
tags String[]
ogTitle String?
ogDescription String? @db.Text
ogImage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+2
View File
@@ -15,6 +15,7 @@ import academicsResearchRoutes from "./routes/academicsResearch.routes.js";
import emailConfigRoutes from "./routes/emailConfig.routes.js"; import emailConfigRoutes from "./routes/emailConfig.routes.js";
import newsMediaRoutes from "./routes/newsMedia.routes.js"; import newsMediaRoutes from "./routes/newsMedia.routes.js";
import importRoutes from "./routes/importRoutes.js"; import importRoutes from "./routes/importRoutes.js";
import healthCheckRoutes from "./routes/healthCheck.route.js";
dotenv.config(); dotenv.config();
@@ -55,6 +56,7 @@ app.use("/api/academics", academicsResearchRoutes);
app.use("/api/email", emailConfigRoutes); app.use("/api/email", emailConfigRoutes);
app.use("/api/newsMedia", newsMediaRoutes); app.use("/api/newsMedia", newsMediaRoutes);
app.use("/api/import", importRoutes); app.use("/api/import", importRoutes);
app.use("/api/health-check", healthCheckRoutes);
const PORT = process.env.PORT || 5008; const PORT = process.env.PORT || 5008;
app.listen(PORT, () => { app.listen(PORT, () => {
@@ -84,7 +84,11 @@ export const createAppointment = async (req, res) => {
<tr> <tr>
<td style="padding: 8px 0;"><b>Date:</b></td> <td style="padding: 8px 0;"><b>Date:</b></td>
<td style="padding: 8px 0;"> <td style="padding: 8px 0;">
${new Date(date).toLocaleDateString()} ${new Date(date).toLocaleDateString("en-GB", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</td> </td>
</tr> </tr>
</table> </table>
@@ -143,18 +147,51 @@ export const getAppointments = async (req, res) => {
const page = parseInt(req.query.page) || 1; const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10; const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const { date, search } = req.query;
const { date, startDate, endDate, search } = req.query;
const where = {}; const where = {};
if (date) { const hasSingleDate = date && date.trim() !== "";
const hasRange =
(startDate && startDate.trim() !== "") ||
(endDate && endDate.trim() !== "");
if (hasSingleDate) {
const start = new Date(date); const start = new Date(date);
start.setHours(0, 0, 0, 0);
const end = new Date(date); const end = new Date(date);
end.setDate(end.getDate() + 1); end.setHours(23, 59, 59, 999);
where.date = { gte: start, lt: end };
where.date = {
gte: start,
lte: end,
};
} }
if (search) { if (!hasSingleDate && hasRange) {
const dateFilter = {};
if (startDate && startDate.trim() !== "") {
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
dateFilter.gte = start;
}
if (endDate && endDate.trim() !== "") {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
dateFilter.lte = end;
}
where.date = dateFilter;
}
if (search && search.trim() !== "") {
where.OR = [ where.OR = [
{ name: { contains: search, mode: "insensitive" } }, { name: { contains: search, mode: "insensitive" } },
{ mobileNumber: { contains: search } }, { mobileNumber: { contains: search } },
@@ -165,24 +202,39 @@ export const getAppointments = async (req, res) => {
const [appointments, total] = await Promise.all([ const [appointments, total] = await Promise.all([
prisma.appointment.findMany({ prisma.appointment.findMany({
where, where,
include: { doctor: true, department: true }, include: {
orderBy: { createdAt: "desc" }, doctor: true,
department: true,
},
orderBy: {
createdAt: "desc",
},
skip, skip,
take: limit, take: limit,
}), }),
prisma.appointment.count({ where }),
prisma.appointment.count({
where,
}),
]); ]);
res.status(200).json({ res.status(200).json({
success: true, success: true,
data: appointments, data: appointments,
pagination: { total, page, limit, totalPages: Math.ceil(total / limit) }, pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res
.status(500) res.status(500).json({
.json({ success: false, message: "Failed to fetch appointments" }); success: false,
message: "Failed to fetch appointments",
});
} }
}; };
+252 -1
View File
@@ -9,12 +9,18 @@ export const getAllDoctors = async (req, res) => {
const doctors = await prisma.doctor.findMany({ const doctors = await prisma.doctor.findMany({
where: admin === "true" ? {} : {isActive: true}, where: admin === "true" ? {} : {isActive: true},
include: { include: {
seo: true,
departments: { departments: {
include: { include: {
department: true, department: true,
timing: true, timing: true,
}, },
}, },
specializations: {
orderBy: {
createdAt: "asc",
},
},
}, },
orderBy: [{globalSortOrder: "asc"}, {name: "asc"}], orderBy: [{globalSortOrder: "asc"}, {name: "asc"}],
}); });
@@ -28,7 +34,25 @@ export const getAllDoctors = async (req, res) => {
workingStatus: doc.workingStatus, workingStatus: doc.workingStatus,
qualification: doc.qualification, qualification: doc.qualification,
isActive: doc.isActive, isActive: doc.isActive,
experience: doc.experience,
professionalSummary: doc.professionalSummary,
globalSortOrder: doc.globalSortOrder, globalSortOrder: doc.globalSortOrder,
specializations: doc.specializations.map((item) => ({
id: item.id,
name: item.name,
description: item.description,
})),
seo: {
seoTitle: doc.seo?.seoTitle ?? "",
metaDescription: doc.seo?.metaDescription ?? "",
focusKeyphrase: doc.seo?.focusKeyphrase ?? "",
slug: doc.seo?.slug ?? "",
tags: doc.seo?.tags ?? [],
ogTitle: doc.seo?.ogTitle ?? "",
ogDescription: doc.seo?.ogDescription ?? "",
ogImage: doc.seo?.ogImage ?? "",
},
departments: doc.departments.map((d) => { departments: doc.departments.map((d) => {
const t = d.timing || {}; const t = d.timing || {};
const timingArray = [ const timingArray = [
@@ -73,6 +97,8 @@ export const getDoctorByDoctorId = async (req, res) => {
const doctor = await prisma.doctor.findUnique({ const doctor = await prisma.doctor.findUnique({
where: {doctorId}, where: {doctorId},
include: { include: {
seo: true,
specializations: true,
departments: { departments: {
include: { include: {
department: true, department: true,
@@ -96,6 +122,24 @@ export const getDoctorByDoctorId = async (req, res) => {
designation: doctor.designation, designation: doctor.designation,
workingStatus: doctor.workingStatus, workingStatus: doctor.workingStatus,
qualification: doctor.qualification, qualification: doctor.qualification,
experience: doctor.experience,
professionalSummary: doctor.professionalSummary,
seo: {
seoTitle: doctor.seo?.seoTitle ?? "",
metaDescription: doctor.seo?.metaDescription ?? "",
focusKeyphrase: doctor.seo?.focusKeyphrase ?? "",
slug: doctor.seo?.slug ?? "",
tags: doctor.seo?.tags ?? [],
ogTitle: doctor.seo?.ogTitle ?? "",
ogDescription: doctor.seo?.ogDescription ?? "",
ogImage: doctor.seo?.ogImage ?? "",
},
specializations:
doctor.specializations?.map((item) => ({
id: item.id,
name: item.name,
description: item.description,
})) ?? [],
departments: doctor.departments.map((d) => ({ departments: doctor.departments.map((d) => ({
departmentId: d.department.departmentId, departmentId: d.department.departmentId,
departmentName: d.department.name, departmentName: d.department.name,
@@ -184,7 +228,48 @@ export const createDoctor = async (req, res) => {
isActive, isActive,
globalSortOrder, globalSortOrder,
departments, departments,
experience,
professionalSummary,
seoTitle,
metaDescription,
focusKeyphrase,
slug,
tags,
specializations,
ogTitle,
ogDescription,
ogImage,
} = req.body; } = req.body;
const messages = [];
if (!doctorId) messages.push("Doctor ID is required");
if (!name?.trim()) messages.push("Doctor name is required");
if (!designation?.trim()) messages.push("Designation is required");
if (!qualification?.trim()) messages.push("Qualification is required");
if (!departments || departments.length === 0) {
messages.push("At least one department is required");
}
if (messages.length > 0) {
return res.status(400).json({
success: false,
message: messages.join(", "),
});
}
const seo = await prisma.seo.create({
data: {
seoTitle,
metaDescription,
focusKeyphrase,
slug: slug ? slug : null,
tags: tags || [],
// Open Graph
ogTitle,
ogDescription,
ogImage,
},
});
const doctor = await prisma.doctor.create({ const doctor = await prisma.doctor.create({
data: { data: {
@@ -194,6 +279,9 @@ export const createDoctor = async (req, res) => {
designation, designation,
workingStatus, workingStatus,
qualification, qualification,
experience: experience ? Number(experience) : null,
professionalSummary,
seoId: seo.id,
isActive: isActive !== undefined ? isActive : true, isActive: isActive !== undefined ? isActive : true,
globalSortOrder: globalSortOrder:
globalSortOrder !== undefined ? Number(globalSortOrder) : 0, globalSortOrder !== undefined ? Number(globalSortOrder) : 0,
@@ -224,6 +312,17 @@ export const createDoctor = async (req, res) => {
}); });
} }
} }
if (specializations?.length) {
await prisma.doctorSpecialization.createMany({
data: specializations
.filter((item) => item.name?.trim())
.map((item) => ({
name: item.name.trim(),
description: item.description?.trim() || null,
doctorId: doctor.id,
})),
});
}
res.status(201).json({ res.status(201).json({
success: true, success: true,
@@ -251,8 +350,31 @@ export const updateDoctor = async (req, res) => {
isActive, isActive,
globalSortOrder, globalSortOrder,
departments, departments,
experience,
professionalSummary,
seoTitle,
metaDescription,
focusKeyphrase,
slug,
tags,
specializations,
} = req.body; } = req.body;
const messages = [];
if (!doctorId) messages.push("Doctor ID is required");
if (!name?.trim()) messages.push("Doctor name is required");
if (!qualification?.trim()) messages.push("Qualification is required");
if (!designation?.trim()) messages.push("Designation is required");
if (!departments || departments.length === 0) {
messages.push("At least one department is required");
}
if (messages.length > 0) {
return res.status(400).json({
success: false,
message: messages.join(", "),
});
}
const doctor = await prisma.doctor.findUnique({where: {doctorId}}); const doctor = await prisma.doctor.findUnique({where: {doctorId}});
if (!doctor) if (!doctor)
return res return res
@@ -268,11 +390,140 @@ export const updateDoctor = async (req, res) => {
workingStatus, workingStatus,
qualification, qualification,
isActive, isActive,
experience: experience ? Number(experience) : null,
professionalSummary,
globalSortOrder: globalSortOrder:
globalSortOrder !== undefined ? Number(globalSortOrder) : undefined, globalSortOrder !== undefined ? Number(globalSortOrder) : undefined,
}, },
}); });
const existingDepartments = await prisma.doctorDepartment.findMany({
where: {
doctorId: doctor.id,
},
include: {
timing: true,
},
});
for (const dep of departments) {
const department = await prisma.department.findUnique({
where: {departmentId: dep.departmentId},
});
if (!department) continue;
const existing = existingDepartments.find(
(d) => d.departmentId === department.id,
);
const newSortOrder =
dep.sortOrder !== undefined ? Number(dep.sortOrder) : 0;
const isSameDepartment = existing && existing.sortOrder === newSortOrder;
const isSameTiming =
JSON.stringify(existing?.timing || {}) ===
JSON.stringify(dep.timing || {});
if (isSameDepartment && isSameTiming) {
continue;
}
let doctorDepartment = existing;
if (!existing) {
doctorDepartment = await prisma.doctorDepartment.create({
data: {
doctorId: doctor.id,
departmentId: department.id,
sortOrder: newSortOrder,
},
});
} else if (existing.sortOrder !== newSortOrder) {
doctorDepartment = await prisma.doctorDepartment.update({
where: {id: existing.id},
data: {
sortOrder: newSortOrder,
},
});
}
if (dep.timing) {
if (existing?.timing) {
await prisma.doctorTiming.update({
where: {id: existing.timing.id},
data: dep.timing,
});
} else {
await prisma.doctorTiming.create({
data: {
doctorDepartmentId: doctorDepartment.id,
...dep.timing,
},
});
}
}
}
if (doctor.seoId) {
await prisma.seo.update({
where: {
id: doctor.seoId,
},
data: {
seoTitle,
metaDescription,
focusKeyphrase,
slug: slug ? slug : null,
tags: tags || [],
},
});
} else {
const seo = await prisma.seo.create({
data: {
seoTitle,
metaDescription,
focusKeyphrase,
slug: slug ? slug : null,
tags: tags || [],
},
});
await prisma.doctor.update({
where: {
id: doctor.id,
},
data: {
seoId: seo.id,
},
});
}
const hasTimingData = departments?.some(
(dep) => dep.timing && Object.keys(dep.timing).length > 0,
);
// Update Specializations
if (Array.isArray(specializations)) {
await prisma.doctorSpecialization.deleteMany({
where: {
doctorId: doctor.id,
},
});
if (specializations.length) {
await prisma.doctorSpecialization.createMany({
data: specializations
.filter((item) => item.name?.trim())
.map((item) => ({
name: item.name.trim(),
description: item.description?.trim() || null,
doctorId: doctor.id,
})),
});
}
}
if (departments && Array.isArray(departments) && hasTimingData) {
const oldRelations = await prisma.doctorDepartment.findMany({ const oldRelations = await prisma.doctorDepartment.findMany({
where: {doctorId: doctor.id}, where: {doctorId: doctor.id},
}); });
@@ -304,12 +555,12 @@ export const updateDoctor = async (req, res) => {
if (dep.timing) { if (dep.timing) {
const {id, doctorDepartmentId, createdAt, updatedAt, ...cleanTiming} = const {id, doctorDepartmentId, createdAt, updatedAt, ...cleanTiming} =
dep.timing; dep.timing;
await prisma.doctorTiming.create({ await prisma.doctorTiming.create({
data: {doctorDepartmentId: newDD.id, ...cleanTiming}, data: {doctorDepartmentId: newDD.id, ...cleanTiming},
}); });
} }
} }
}
res res
.status(200) .status(200)
@@ -0,0 +1,440 @@
import prisma from "../prisma/client.js";
import { sendEmail } from "../utils/sendEmail.js";
import { getEmailsByType } from "../utils/getEmailByTypes.js";
export const getAllCategories = async (req, res) => {
try {
const { admin } = req.query;
const categories = await prisma.healthCheckCategory.findMany({
where: admin === "true" ? {} : { isActive: true },
orderBy: { sortOrder: "asc" },
include: {
_count: { select: { packages: true } },
},
});
return res.status(200).json({ success: true, data: categories });
} catch (error) {
return res
.status(500)
.json({ success: false, message: "Failed to fetch categories" });
}
};
export const createCategory = async (req, res) => {
try {
const { name, slug, description, isActive, sortOrder } = req.body;
const category = await prisma.healthCheckCategory.create({
data: {
name,
slug: slug || null,
description,
isActive: isActive ?? true,
sortOrder: sortOrder ? Number(sortOrder) : 1000,
},
});
return res
.status(201)
.json({ success: true, message: "Category created", data: category });
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to create category" });
}
};
export const updateCategory = async (req, res) => {
try {
const { id } = req.params;
const data = { ...req.body };
delete data.id;
delete data._count;
delete data.createdAt;
delete data.updatedAt;
if (data.sortOrder !== undefined) data.sortOrder = Number(data.sortOrder);
if (data.slug === "") data.slug = null;
const updatedCategory = await prisma.$transaction(async (tx) => {
const category = await tx.healthCheckCategory.update({
where: { id: Number(id) },
data,
});
if (data.isActive === false) {
await tx.healthPackage.updateMany({
where: { categoryId: Number(id) },
data: { isActive: false },
});
}
return category;
});
return res.status(200).json({
success: true,
message: "Category updated",
data: updatedCategory,
});
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to update category" });
}
};
export const deleteCategory = async (req, res) => {
try {
const { id } = req.params;
await prisma.healthCheckCategory.delete({
where: { id: Number(id) },
});
return res
.status(200)
.json({ success: true, message: "Category deleted successfully" });
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message:
"Failed to delete category. Ensure no packages are linked to it.",
});
}
};
export const getAllPackages = async (req, res) => {
try {
const { admin, categorySlug } = req.query;
const packages = await prisma.healthPackage.findMany({
where: {
AND: [
admin === "true" ? {} : { isActive: true },
categorySlug ? { category: { slug: categorySlug } } : {},
],
},
include: { category: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return res.status(200).json({ success: true, data: packages });
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to fetch packages" });
}
};
export const createPackage = async (req, res) => {
try {
const {
name,
slug,
description,
price,
image,
discountedPrice,
inclusions,
categoryId,
isActive,
isFeatured,
sortOrder,
} = req.body;
const healthPackage = await prisma.healthPackage.create({
data: {
name,
slug,
description,
price,
image,
discountedPrice,
inclusions,
categoryId: Number(categoryId),
isActive: isActive ?? true,
isFeatured: isFeatured ?? false,
sortOrder: sortOrder ? Number(sortOrder) : 1000,
},
});
return res
.status(201)
.json({ success: true, message: "Package created", data: healthPackage });
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to create package" });
}
};
export const updatePackage = async (req, res) => {
try {
const { id } = req.params;
const data = { ...req.body };
delete data.id;
delete data.category;
if (data.categoryId) data.categoryId = Number(data.categoryId);
if (data.sortOrder) data.sortOrder = Number(data.sortOrder);
const updated = await prisma.healthPackage.update({
where: { id: Number(id) },
data,
});
return res
.status(200)
.json({ success: true, message: "Package updated", data: updated });
} catch (error) {
console.error(error);
return res.status(500).json({ success: false, message: "Update failed" });
}
};
export const deletePackage = async (req, res) => {
try {
const { id } = req.params;
await prisma.healthPackage.delete({ where: { id: Number(id) } });
return res.status(200).json({ success: true, message: "Package deleted" });
} catch (error) {
console.error(error);
return res.status(500).json({ success: false, message: "Delete failed" });
}
};
export const createPackageInquiry = async (req, res) => {
try {
const {
fullName,
mobileNumber,
email,
age,
gender,
preferredDate,
packageId,
message,
} = req.body;
const inquiry = await prisma.healthPackageInquiry.create({
data: {
fullName,
mobileNumber,
email,
age: age ? Number(age) : null,
gender,
preferredDate: preferredDate ? new Date(preferredDate) : null,
message,
packageId: Number(packageId),
},
include: {
healthPackage: true,
},
});
try {
const emailList = await getEmailsByType("HCINQUIRY");
if (emailList) {
await sendEmail({
to: emailList,
subject: "New Health Checkup Package Inquiry",
html: `
<div style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;">
<div style="max-width: 600px; margin: auto; background: #ffffff; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 10px rgba(0,0,0,0.05);">
<!-- Header -->
<div style="background-color: #0d6efd; color: #ffffff; padding: 20px;">
<h2 style="margin: 0;">GG Hospital</h2>
<p style="margin: 5px 0 0; font-size: 14px;">
New Health Checkup Package Inquiry
</p>
</div>
<!-- Body -->
<div style="padding: 20px; color: #333;">
<h3 style="margin-top: 0;">Inquirer Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; width: 35%;"><b>Name:</b></td>
<td style="padding: 8px 0;">${fullName}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Phone:</b></td>
<td style="padding: 8px 0;">${mobileNumber}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Email:</b></td>
<td style="padding: 8px 0;">${email || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Age:</b></td>
<td style="padding: 8px 0;">${age || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Gender:</b></td>
<td style="padding: 8px 0;">${gender || "-"}</td>
</tr>
</table>
<h3 style="margin-top: 20px;">Package Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; width: 35%;"><b>Package Name:</b></td>
<td style="padding: 8px 0;">${inquiry.healthPackage?.name || "Unknown Package"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Preferred Date:</b></td>
<td style="padding: 8px 0;">
${
preferredDate
? new Date(preferredDate).toLocaleDateString("en-GB", {
day: "2-digit",
month: "long",
year: "numeric",
})
: "Not specified"
}
</td>
</tr>
</table>
<!-- Message Box -->
<div style="margin-top: 20px;">
<h3>Message</h3>
<div style="
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
">
${message ? message.replace(/\n/g, "<br/>") : "-"}
</div>
</div>
</div>
<!-- Footer -->
<div style="background: #f1f1f1; padding: 15px; text-align: center; font-size: 12px; color: #666;">
This inquiry was submitted via the GG Hospital website.
</div>
</div>
</div>
`,
});
}
} catch (err) {
console.error("Email failed:", err);
}
return res.status(201).json({
success: true,
message: "Booking inquiry sent successfully",
data: inquiry,
});
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to submit inquiry" });
}
};
export const getPackageBySlug = async (req, res) => {
try {
const { slug } = req.params;
const healthPackage = await prisma.healthPackage.findFirst({
where: { slug, isActive: true },
include: { category: true },
});
if (!healthPackage) {
return res
.status(404)
.json({ success: false, message: "Package not found" });
}
return res.status(200).json({ success: true, data: healthPackage });
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to fetch package" });
}
};
export const getAllInquiries = async (req, res) => {
try {
const { page = 1, limit = 10, filterDate, startDate, endDate } = req.query;
const queryPage = parseInt(page);
const queryLimit = parseInt(limit);
const skip = (queryPage - 1) * queryLimit;
let where = {};
if (filterDate) {
where.preferredDate = {
gte: new Date(`${filterDate}T00:00:00.000Z`),
lte: new Date(`${filterDate}T23:59:59.999Z`),
};
} else if (startDate || endDate) {
where.preferredDate = {};
if (startDate) {
where.preferredDate.gte = new Date(`${startDate}T00:00:00.000Z`);
}
if (endDate) {
where.preferredDate.lte = new Date(`${endDate}T23:59:59.999Z`);
}
}
const [total, inquiries] = await prisma.$transaction([
prisma.healthPackageInquiry.count({ where }),
prisma.healthPackageInquiry.findMany({
where,
skip,
take: queryLimit,
include: {
healthPackage: {
include: { category: true },
},
},
orderBy: { createdAt: "desc" },
}),
]);
return res.status(200).json({
success: true,
data: inquiries,
pagination: {
total,
page: queryPage,
limit: queryLimit,
totalPages: Math.ceil(total / queryLimit),
},
});
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to fetch inquiries" });
}
};
+39
View File
@@ -0,0 +1,39 @@
import express from "express";
import {
// Categories
getAllCategories,
getPackageBySlug,
createCategory,
updateCategory,
deleteCategory,
// Packages
getAllPackages,
createPackage,
updatePackage,
deletePackage,
// Inquiries
createPackageInquiry,
getAllInquiries,
} from "../controllers/healthCheck.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router();
router.get("/packages", getAllPackages);
router.get("/packages/:slug", getPackageBySlug);
router.get("/categories", getAllCategories);
router.post("/inquiry", createPackageInquiry);
router.get("/inquiries", jwtAuthMiddleware, getAllInquiries);
router.post("/", jwtAuthMiddleware, createPackage);
router.patch("/:id", jwtAuthMiddleware, updatePackage);
router.delete("/:id", jwtAuthMiddleware, deletePackage);
router.post("/categories", jwtAuthMiddleware, createCategory);
router.patch("/categories/:id", jwtAuthMiddleware, updateCategory);
router.delete("/categories/:id", jwtAuthMiddleware, deleteCategory);
export default router;
+2
View File
@@ -23,6 +23,7 @@ import AcademicsPage from "./pages/Academics";
import NewsPage from "./pages/newsMedia"; import NewsPage from "./pages/newsMedia";
import BlogDetail from "./pages/BlogDetails"; import BlogDetail from "./pages/BlogDetails";
import ImportData from "./pages/ImportData"; import ImportData from "./pages/ImportData";
import HealthPackagePage from "./pages/HealthPackagePage";
export default function App() { export default function App() {
return ( return (
@@ -51,6 +52,7 @@ export default function App() {
<Route path="/academics" element={<AcademicsPage />} /> <Route path="/academics" element={<AcademicsPage />} />
<Route path="/news" element={<NewsPage />} /> <Route path="/news" element={<NewsPage />} />
<Route path="/import" element={<ImportData />} /> <Route path="/import" element={<ImportData />} />
<Route path="/health-check" element={<HealthPackagePage />} />
</Route> </Route>
</Route> </Route>
+4
View File
@@ -4,12 +4,16 @@ export const getAppointmentsApi = async (
page = 1, page = 1,
limit = 10, limit = 10,
date = "", date = "",
startDate = "",
endDate = "",
search = "", search = "",
) => { ) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: String(page), page: String(page),
limit: String(limit), limit: String(limit),
...(date && { date }), ...(date && { date }),
...(startDate && { startDate }),
...(endDate && { endDate }),
...(search && { search }), ...(search && { search }),
}); });
const res = await apiClient.get(`/appointments/getall?${params}`); const res = await apiClient.get(`/appointments/getall?${params}`);
+3 -1
View File
@@ -8,8 +8,10 @@ export interface Doctor {
designation?: string; designation?: string;
workingStatus?: string; workingStatus?: string;
qualification?: string; qualification?: string;
isActive: boolean;
globalSortOrder: number;
departments: { departments?: {
departmentId: string; departmentId: string;
timing?: { timing?: {
monday?: string; monday?: string;
+160
View File
@@ -0,0 +1,160 @@
import apiClient from "@/api/client";
import toast from "react-hot-toast";
export interface HealthPackage {
id?: number;
name: string;
slug: string;
description?: string;
price: number;
image?: string;
discountedPrice?: number;
inclusions: Record<string, string[]>;
categoryId: number;
isActive: boolean;
isFeatured: boolean;
sortOrder: number;
category?: {
name: string;
};
}
export interface HealthCategory {
id: number;
name: string;
slug: string;
sortOrder: number;
isActive: boolean;
}
export interface HealthInquiry {
id: number;
fullName: string;
mobileNumber: string;
email?: string;
age: string;
gender: string;
preferredDate: string;
message?: string;
createdAt: string;
healthPackage?: {
name: string;
category?: {
name: string;
};
};
}
export const getHealthCategoriesApi = async () => {
const res = await apiClient.get("/health-check/categories?admin=true");
return res.data;
};
export const getHealthPackagesApi = async () => {
const res = await apiClient.get("/health-check/packages?admin=true");
return res.data;
};
export const createHealthPackageApi = async (data: Partial<HealthPackage>) => {
try {
const res = await apiClient.post("/health-check", data);
toast.success("Package created successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to create package");
throw error;
}
};
export const updateHealthPackageApi = async (
id: number,
data: Partial<HealthPackage>,
) => {
try {
const res = await apiClient.patch(`/health-check/${id}`, data);
toast.success("Package updated successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to update package");
throw error;
}
};
export const deleteHealthPackageApi = async (id: number) => {
try {
const res = await apiClient.delete(`/health-check/${id}`);
toast.success("Package deleted successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to delete package");
throw error;
}
};
export const createCategoryApi = async (data: {
name: string;
slug: string;
sortOrder: number;
}) => {
try {
const res = await apiClient.post("/health-check/categories", data);
toast.success("Category created successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to create category");
throw error;
}
};
export const updateCategoryApi = async (id: number, data: any) => {
try {
const res = await apiClient.patch(`/health-check/categories/${id}`, data);
toast.success("Category updated successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to update category");
throw error;
}
};
export const deleteCategoryApi = async (id: number) => {
try {
const res = await apiClient.delete(`/health-check/categories/${id}`);
toast.success("Category deleted successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to delete category");
throw error;
}
};
export const getAllInquiriesApi = async (
page = 1,
limit = 10,
filterDate = "",
startDate = "",
endDate = "",
) => {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
});
if (filterDate) params.append("filterDate", filterDate);
if (startDate) params.append("startDate", startDate);
if (endDate) params.append("endDate", endDate);
const res = await apiClient.get(
`/health-check/inquiries?${params.toString()}`,
);
return res.data;
};
@@ -1,12 +1,17 @@
import {useState, useRef} from "react"; import { useState, useRef } from "react";
import {Button} from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {User, X, Loader2} from "lucide-react"; import { User, X, Loader2 } from "lucide-react";
import axios from "axios"; import axios from "axios";
interface BytescaleUploaderProps { interface BytescaleUploaderProps {
value: string; value: string;
onChange: (url: string) => void; onChange: (url: string) => void;
folderPath: "/doctors" | "/departments" | "/news" | "/blog"; folderPath:
| "/doctors"
| "/departments"
| "/news"
| "/blog"
| "/health-packages";
} }
export function BytescaleUploader({ export function BytescaleUploader({
@@ -40,7 +45,7 @@ export function BytescaleUploader({
}, },
}); });
const {fileUrl} = response.data; const { fileUrl } = response.data;
onChange(fileUrl); onChange(fileUrl);
} catch (e: any) { } catch (e: any) {
console.error("Upload Error:", e); console.error("Upload Error:", e);
@@ -0,0 +1,273 @@
import { useState, useEffect, useCallback } from "react";
import { getAllInquiriesApi, HealthInquiry } from "@/api/healthCheck";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Loader2, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
export default function PackageInquiriesTab() {
const [inquiries, setInquiries] = useState<HealthInquiry[]>([]);
const [loading, setLoading] = useState(true);
const [filterDate, setFilterDate] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const fetchInquiries = useCallback(async () => {
setLoading(true);
try {
const res = await getAllInquiriesApi(
currentPage,
itemsPerPage,
filterDate,
startDate,
endDate,
);
setInquiries(res.data || []);
setTotalItems(res.pagination?.total || 0);
setTotalPages(res.pagination?.totalPages || 1);
} catch (err) {
console.error("Failed to fetch inquiries", err);
} finally {
setLoading(false);
}
}, [currentPage, itemsPerPage, filterDate, startDate, endDate]);
useEffect(() => {
fetchInquiries();
}, [fetchInquiries]);
const handleFilterChange = (
setter: React.Dispatch<React.SetStateAction<string>>,
value: string,
) => {
setter(value);
setCurrentPage(1);
};
const indexOfFirstItem = (currentPage - 1) * itemsPerPage;
const indexOfLastItem = Math.min(currentPage * itemsPerPage, totalItems);
return (
<Card>
<CardHeader className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<CardTitle className="text-xl">Package Inquiries</CardTitle>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Specific Date
</label>
<Input
type="date"
value={filterDate}
onChange={(e) =>
handleFilterChange(setFilterDate, e.target.value)
}
className="w-[140px] text-sm"
disabled={!!startDate || !!endDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
From
</label>
<Input
type="date"
value={startDate}
onChange={(e) => handleFilterChange(setStartDate, e.target.value)}
className="w-[140px] text-sm"
disabled={!!filterDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
To
</label>
<Input
type="date"
value={endDate}
onChange={(e) => handleFilterChange(setEndDate, e.target.value)}
className="w-[140px] text-sm"
disabled={!!filterDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Rows
</label>
<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>
<option value={50}>50 / page</option>
</select>
</div>
<Button variant="outline" onClick={fetchInquiries} disabled={loading}>
<RefreshCw
className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
</CardHeader>
<CardContent className="p-0 sm:p-6 sm:pt-0">
<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-[150px] font-bold bg-background">
Requested Date
</TableHead>
<TableHead className="w-[220px] font-bold bg-background">
Patient Details
</TableHead>
<TableHead className="w-[250px] font-bold bg-background">
Requested Package
</TableHead>
<TableHead className="w-[120px] font-bold bg-background">
Age/Gender
</TableHead>
<TableHead className="w-[250px] font-bold bg-background">
Message
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : inquiries.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground py-10">
No inquiries found for the selected criteria
</TableCell>
</TableRow>
) : (
inquiries.map((inq) => (
<TableRow key={inq.id} className="hover:bg-muted/50">
<TableCell>
<div className="font-semibold text-primary">
{new Date(inq.preferredDate).toLocaleDateString()}
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Submitted:{" "}
{new Date(inq.createdAt).toLocaleDateString()}
</div>
</TableCell>
<TableCell>
<div className="font-semibold text-base">
{inq.fullName}
</div>
<div className="text-sm">{inq.mobileNumber}</div>
<div className="text-xs text-muted-foreground">
{inq.email || "-"}
</div>
</TableCell>
<TableCell>
<div className="font-semibold text-sm truncate">
{inq.healthPackage?.name || "N/A"}
</div>
</TableCell>
<TableCell>
<div className="font-medium">
{inq.age} yrs / {inq.gender}
</div>
</TableCell>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-sm italic line-clamp-3 text-muted-foreground whitespace-pre-wrap cursor-pointer">
{inq.message || "No message provided."}
</div>
</TooltipTrigger>
<TooltipContent className="max-w-md whitespace-pre-wrap">
{inq.message || "No message provided."}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && totalItems > 0 && (
<div className="flex flex-col sm:flex-row items-center justify-between px-2 py-4 border-t gap-4 mt-2">
<div className="text-sm text-muted-foreground">
Showing{" "}
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
<span className="font-semibold">{indexOfLastItem}</span> of{" "}
<span className="font-semibold">{totalItems}</span> inquiries
</div>
<div className="flex items-center gap-6">
<div className="text-sm font-semibold">
Page {currentPage} of {totalPages || 1}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-9 w-9"
onClick={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
disabled={currentPage === 1}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages || totalPages === 0}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}
+6 -1
View File
@@ -15,6 +15,10 @@ export default function Sidebar() {
name: "Doctor", name: "Doctor",
path: "/doctor", path: "/doctor",
}, },
{
name: "Health Check",
path: "/health-check",
},
{ {
name: "Appointments", name: "Appointments",
path: "/appointment", path: "/appointment",
@@ -65,7 +69,8 @@ export default function Sidebar() {
<Link key={item.path} to={item.path}> <Link key={item.path} to={item.path}>
<Button <Button
variant={active ? "secondary" : "ghost"} variant={active ? "secondary" : "ghost"}
className="w-full justify-start"> className="w-full justify-start"
>
{item.name} {item.name}
</Button> </Button>
</Link> </Link>
+2 -2
View File
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
@@ -25,7 +25,7 @@ const buttonVariants = cva(
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8", icon: "size-8",
"icon-xs": "icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+5 -2
View File
@@ -59,7 +59,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( 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", "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-popover p-4 text-sm text-popover-foreground 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 className
)} )}
{...props} {...props}
@@ -127,7 +127,10 @@ function DialogTitle({
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)} className={cn(
"text-base leading-none font-medium",
className
)}
{...props} {...props}
/> />
) )
+190
View File
@@ -0,0 +1,190 @@
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none 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", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+88
View File
@@ -0,0 +1,88 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 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-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 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}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+54 -3
View File
@@ -40,7 +40,8 @@ export default function AppointmentPage() {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [filterDoctor, setFilterDoctor] = useState(""); const [filterDoctor, setFilterDoctor] = useState("");
const [filterDate, setFilterDate] = useState(""); const [filterDate, setFilterDate] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [viewOpen, setViewOpen] = useState(false); const [viewOpen, setViewOpen] = useState(false);
const [viewData, setViewData] = useState<any>(null); const [viewData, setViewData] = useState<any>(null);
@@ -56,6 +57,8 @@ export default function AppointmentPage() {
currentPage, currentPage,
itemsPerPage, itemsPerPage,
filterDate, filterDate,
startDate,
endDate,
searchText, searchText,
); );
setAppointments(res?.data || []); setAppointments(res?.data || []);
@@ -66,7 +69,7 @@ export default function AppointmentPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [currentPage, itemsPerPage, filterDate, searchText]); }, [currentPage, itemsPerPage, filterDate, startDate, endDate, searchText]);
useEffect(() => { useEffect(() => {
fetchAll(); fetchAll();
@@ -116,7 +119,11 @@ export default function AppointmentPage() {
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4"> <div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<h1 className="text-3xl font-bold">Appointments</h1> <h1 className="text-3xl font-bold">Appointments</h1>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-4 items-end">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Search
</label>
<Input <Input
placeholder="Search name / phone..." placeholder="Search name / phone..."
value={searchText} value={searchText}
@@ -126,7 +133,12 @@ export default function AppointmentPage() {
}} }}
className="w-[220px] text-base" className="w-[220px] text-base"
/> />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Date
</label>
<Input <Input
type="date" type="date"
value={filterDate} value={filterDate}
@@ -135,8 +147,46 @@ export default function AppointmentPage() {
setCurrentPage(1); setCurrentPage(1);
}} }}
className="w-[160px] text-base" className="w-[160px] text-base"
disabled={!!startDate || !!endDate}
/> />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
From
</label>
<Input
type="date"
value={startDate}
onChange={(e) => {
setStartDate(e.target.value);
setCurrentPage(1);
}}
className="w-[160px] text-base"
disabled={!!filterDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
To
</label>
<Input
type="date"
value={endDate}
onChange={(e) => {
setEndDate(e.target.value);
setCurrentPage(1);
}}
className="w-[160px] text-base"
disabled={!!filterDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Rows
</label>
<select <select
value={itemsPerPage} value={itemsPerPage}
onChange={(e) => { onChange={(e) => {
@@ -149,6 +199,7 @@ export default function AppointmentPage() {
<option value={10}>10 / page</option> <option value={10}>10 / page</option>
<option value={20}>20 / page</option> <option value={20}>20 / page</option>
</select> </select>
</div>
<Button <Button
variant="outline" variant="outline"
+33 -2
View File
@@ -10,6 +10,7 @@ import Table from "@editorjs/table";
import CodeTool from "@editorjs/code"; import CodeTool from "@editorjs/code";
import Embed from "@editorjs/embed"; import Embed from "@editorjs/embed";
import Delimiter from "@editorjs/delimiter"; import Delimiter from "@editorjs/delimiter";
import axios from "axios";
import { import {
createBlogApi, createBlogApi,
@@ -23,6 +24,7 @@ import {Input} from "@/components/ui/input";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
export default function BlogEditorPage() { export default function BlogEditorPage() {
const baseURL = import.meta.env.VITE_API_URL;
const {id} = useParams(); const {id} = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -79,12 +81,41 @@ export default function BlogEditorPage() {
config: { config: {
uploader: { uploader: {
uploadByFile: async (file: File) => { uploadByFile: async (file: File) => {
const res = await uploadImageApi(file); if (file.size > 5 * 1024 * 1024) {
alert("File is too large (Max 5MB)");
return {success: 0, file: {url: ""}};
}
const formData = new FormData();
formData.append("file", file);
formData.append("folderPath", "/blog");
try {
const response = await axios.post(
`${baseURL}/upload`,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
},
);
return { return {
success: 1, success: 1,
file: {url: res.file.url}, file: {url: response.data.fileUrl},
}; };
} catch (e: any) {
console.error("EditorJS Image Upload Error:", e);
const errorMessage =
e.response?.data?.error || e.message || "Upload failed";
alert(`Upload Error: ${errorMessage}`);
return {
success: 0,
file: {url: ""},
};
}
}, },
}, },
}, },
+437 -4
View File
@@ -41,6 +41,7 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
interface Department { interface Department {
departmentId: string; departmentId: string;
@@ -83,6 +84,21 @@ export default function DoctorPage() {
isActive: true, isActive: true,
globalSortOrder: 0, globalSortOrder: 0,
departments: [], departments: [],
professionalSummary: "",
seoTitle: "",
metaDescription: "",
ogTitle: "",
ogDescription: "",
ogImage: "",
specializations: [
{
name: "",
description: "",
},
],
focusKeyphrase: "",
slug: "",
tags: [],
}); });
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
@@ -110,7 +126,8 @@ export default function DoctorPage() {
fetchAll(); fetchAll();
}, [fetchAll]); }, [fetchAll]);
const filteredDoctors = doctors.filter((doc) => { const filteredDoctors = doctors
.filter((doc) => {
const matchesSearch = const matchesSearch =
doc.name.toLowerCase().includes(searchText.toLowerCase()) || doc.name.toLowerCase().includes(searchText.toLowerCase()) ||
doc.doctorId.toLowerCase().includes(searchText.toLowerCase()); doc.doctorId.toLowerCase().includes(searchText.toLowerCase());
@@ -120,6 +137,24 @@ export default function DoctorPage() {
: true; : true;
return matchesSearch && matchesDepartment; return matchesSearch && matchesDepartment;
})
.sort((a, b) => {
if (!filterDepartment) {
return a.globalSortOrder - b.globalSortOrder;
}
const aDept = a.departments.find(
(d: any) => d.departmentId === filterDepartment,
);
const bDept = b.departments.find(
(d: any) => d.departmentId === filterDepartment,
);
return (
(aDept?.deptSortOrder ?? Number.MAX_SAFE_INTEGER) -
(bDept?.deptSortOrder ?? Number.MAX_SAFE_INTEGER)
);
}); });
useEffect(() => { useEffect(() => {
@@ -132,15 +167,29 @@ export default function DoctorPage() {
const currentItems = filteredDoctors.slice(indexOfFirstItem, indexOfLastItem); const currentItems = filteredDoctors.slice(indexOfFirstItem, indexOfLastItem);
function handleChange(e: any) { function handleChange(e: any) {
const value = let value =
e.target.type === "number" ? Number(e.target.value) : e.target.value; e.target.type === "number" ? Number(e.target.value) : e.target.value;
if (e.target.name === "slug") {
value = value
.toLowerCase()
.replace(/\s+/g, "-") // replace spaces with -
.replace(/[^\w-]+/g, "") // remove special chars
.replace(/--+/g, "-"); // remove duplicate -
}
setForm({ ...form, [e.target.name]: value }); setForm({ ...form, [e.target.name]: value });
} }
const handleToggleStatus = async (doc: any) => { const handleToggleStatus = async (doc: any) => {
try { try {
const updatedDoc = { ...doc, isActive: !doc.isActive }; const newStatus = !doc.isActive;
await updateDoctorApi(doc.doctorId, updatedDoc);
const payload = {
isActive: newStatus,
};
await updateDoctorApi(doc.doctorId, payload);
fetchAll(); fetchAll();
} catch (err) { } catch (err) {
console.error("Failed to update status", err); console.error("Failed to update status", err);
@@ -196,9 +245,25 @@ export default function DoctorPage() {
designation: "", designation: "",
workingStatus: "", workingStatus: "",
qualification: "", qualification: "",
experience: "",
professionalSummary: "",
isActive: true, isActive: true,
globalSortOrder: 0, globalSortOrder: 0,
specializations: [
{
name: "",
description: "",
},
],
departments: [], departments: [],
seoTitle: "",
metaDescription: "",
focusKeyphrase: "",
slug: "",
tags: [],
ogTitle: "",
ogDescription: "",
ogImage: "",
}); });
setOpenModal(true); setOpenModal(true);
} }
@@ -218,6 +283,27 @@ export default function DoctorPage() {
qualification: doc.qualification, qualification: doc.qualification,
isActive: doc.isActive ?? true, isActive: doc.isActive ?? true,
globalSortOrder: doc.globalSortOrder ?? 0, globalSortOrder: doc.globalSortOrder ?? 0,
experience: doc.experience || "",
professionalSummary: doc.professionalSummary || "",
seoTitle: doc.seo?.seoTitle || "",
metaDescription: doc.seo?.metaDescription || "",
focusKeyphrase: doc.seo?.focusKeyphrase || "",
slug: doc.seo?.slug || "",
tags: doc.seo?.tags || [],
ogTitle: doc.seo?.ogTitle || "",
ogDescription: doc.seo?.ogDescription || "",
ogImage: doc.seo?.ogImage || "",
specializations: doc.specializations?.length
? doc.specializations.map((item: any) => ({
name: item.name || "",
description: item.description || "",
}))
: [
{
name: "",
description: "",
},
],
departments: timingData.map((d: any) => ({ departments: timingData.map((d: any) => ({
departmentId: d.departmentId, departmentId: d.departmentId,
sortOrder: d.deptSortOrder ?? 0, sortOrder: d.deptSortOrder ?? 0,
@@ -230,6 +316,8 @@ export default function DoctorPage() {
} }
} }
console.log("Current form state:", form); // Debug log to check form state
async function handleSubmit() { async function handleSubmit() {
try { try {
if (editing) { if (editing) {
@@ -569,6 +657,24 @@ export default function DoctorPage() {
className="text-base" className="text-base"
/> />
</div> </div>
<div className="space-y-1">
<label className="text-sm font-semibold">
Brief Professional Summary
</label>
<Textarea
name="professionalSummary"
placeholder="Write a brief professional summary about the doctor..."
value={form.professionalSummary || ""}
onChange={(e) =>
setForm({
...form,
professionalSummary: e.target.value,
})
}
className="min-h-[120px] text-base"
/>
</div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm font-semibold"> <label className="text-sm font-semibold">
Qualification Qualification
@@ -581,6 +687,25 @@ export default function DoctorPage() {
className="text-base" className="text-base"
/> />
</div> </div>
<div className="space-y-1">
<label className="text-sm font-semibold">
Years of Experience
</label>
<Input
name="experience"
type="number"
min={0}
placeholder="e.g. 15"
value={form.experience || ""}
onChange={handleChange}
className="text-base"
/>
<p className="text-xs text-muted-foreground">
Enter total years of professional experience
</p>
</div>
</div> </div>
<div className="p-5 border rounded-md bg-muted/20"> <div className="p-5 border rounded-md bg-muted/20">
@@ -607,6 +732,314 @@ export default function DoctorPage() {
})} })}
</div> </div>
</div> </div>
<div className="space-y-4 p-5 border rounded-md bg-muted/20">
<div className="flex items-center justify-between">
<p className="text-base font-bold">Specializations</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setForm({
...form,
specializations: [
...(form.specializations || []),
{
name: "",
description: "",
},
],
})
}
>
+ Add
</Button>
</div>
{form.specializations?.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No specializations added
</p>
) : (
<div className="space-y-4">
{form.specializations?.map(
(specialization: any, index: number) => (
<div
key={index}
className="border rounded-lg p-4 space-y-3 bg-background"
>
<div className="flex items-center justify-between">
<p className="font-medium text-sm">
Specialization {index + 1}
</p>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const updated = form.specializations.filter(
(_: any, i: number) => i !== index,
);
setForm({
...form,
specializations: updated,
});
}}
>
Remove
</Button>
</div>
<div className="space-y-2">
<Label>Name</Label>
<Input
placeholder="e.g. Cardiology"
value={specialization.name}
onChange={(e) => {
const updated = [...form.specializations];
updated[index].name = e.target.value;
setForm({
...form,
specializations: updated,
});
}}
/>
</div>
<div className="space-y-2">
<Label>Doctor-specific Description</Label>
<Textarea
placeholder="Describe how this doctor specializes in this area..."
value={specialization.description}
onChange={(e) => {
const updated = [...form.specializations];
updated[index].description = e.target.value;
setForm({
...form,
specializations: updated,
});
}}
/>
</div>
</div>
),
)}
</div>
)}
</div>
<div className="space-y-4 p-5 border rounded-md bg-muted/20">
<div className="flex items-center justify-between">
<p className="text-base font-bold">SEO Settings</p>
<Badge variant="secondary">Optional</Badge>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">SEO Title</Label>
<Input
name="seoTitle"
placeholder="Best Cardiologist in Kochi | Dr John Doe"
value={form.seoTitle || ""}
onChange={handleChange}
className="text-base"
/>
<p className="text-xs text-muted-foreground">
Title shown in Google search results
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Meta Description
</Label>
<Textarea
name="metaDescription"
placeholder="Short description shown in Google search..."
value={form.metaDescription || ""}
onChange={(e) =>
setForm({
...form,
metaDescription: e.target.value,
})
}
className="min-h-[100px] text-base"
/>
<p className="text-xs text-muted-foreground">
Recommended: 150160 characters
</p>
</div>
<div className="border-t pt-5 space-y-4">
<div className="flex items-center justify-between">
<p className="text-base font-bold">
Open Graph (Social Preview)
</p>
<Badge variant="secondary">Optional</Badge>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">OG Title</Label>
<Input
name="ogTitle"
placeholder="Title for WhatsApp / Facebook sharing"
value={form.ogTitle || ""}
onChange={handleChange}
className="text-base"
/>
<p className="text-xs text-muted-foreground">
If empty, SEO title will be used
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
OG Description
</Label>
<Textarea
name="ogDescription"
placeholder="Description for social sharing..."
value={form.ogDescription || ""}
onChange={(e) =>
setForm({
...form,
ogDescription: e.target.value,
})
}
className="min-h-[100px] text-base"
/>
<p className="text-xs text-muted-foreground">
If empty, meta description will be used
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">OG Image</Label>
<BytescaleUploader
value={form.ogImage}
folderPath="/doctor-og"
onChange={(url) =>
setForm({
...form,
ogImage: url,
})
}
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Focus Keyphrase
</Label>
<Input
name="focusKeyphrase"
placeholder="best cardiologist in kochi"
value={form.focusKeyphrase || ""}
onChange={handleChange}
className="text-base"
/>
<p className="text-xs text-muted-foreground">
Main keyword people may search in Google
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">URL Slug</Label>
<Input
name="slug"
placeholder="dr-john-doe"
value={form.slug || ""}
onChange={handleChange}
className="text-base"
/>
<p className="text-xs text-muted-foreground">
URL:
<span className="font-medium">
{" "}
/doctors/
{form.slug || "doctor-slug"}
</span>
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Tags / Keywords
</Label>
<div className="flex flex-wrap gap-2 border rounded-md p-3 min-h-[48px] bg-background">
{form.tags?.map((tag: string, index: number) => (
<div
key={index}
className="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm flex items-center gap-2"
>
{tag}
<button
type="button"
onClick={() => {
const updated = form.tags.filter(
(_: string, i: number) => i !== index,
);
setForm({
...form,
tags: updated,
});
}}
>
×
</button>
</div>
))}
<Input
placeholder="Type keyword and press Enter"
className="border-0 shadow-none focus-visible:ring-0 min-w-[220px]"
onKeyDown={(e) => {
if (
e.key === "Enter" &&
e.currentTarget.value.trim()
) {
e.preventDefault();
setForm({
...form,
tags: [
...(form.tags || []),
e.currentTarget.value.trim(),
],
});
e.currentTarget.value = "";
}
}}
/>
</div>
</div>
</div>
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
File diff suppressed because it is too large Load Diff
+1
View File
@@ -233,6 +233,7 @@ export default function EmailPage() {
<option value="CANDIDATE">CANDIDATE</option> <option value="CANDIDATE">CANDIDATE</option>
<option value="ACADEMICS">ACADEMICS</option> <option value="ACADEMICS">ACADEMICS</option>
<option value="INQUIRY">INQUIRY</option> <option value="INQUIRY">INQUIRY</option>
<option value="HCINQUIRY">HC-APPOINTMENT</option>
</select> </select>
<select <select