Compare commits

...

49 Commits

Author SHA1 Message Date
Kailasdevdas 8b563e45a2 feat: health check preview url 2026-05-26 12:58:13 +05:30
Kailasdevdas e6d77b72b4 feat: health check seo preview 2026-05-26 12:41:43 +05:30
Kailasdevdas 3d7b8eef6c Merge branch 'feat/seo-preview' into feat/healthcheck-seo 2026-05-26 12:39:17 +05:30
rishalkv 5aae2824ef fix: edge case creation of og 2026-05-26 12:36:11 +05:30
Kailasdevdas f3cb4aee91 Merge branch 'feat/seo-preview' into feat/healthcheck-seo 2026-05-26 12:34:21 +05:30
rishalkv 3af6401429 fix: og title description 2026-05-26 12:33:51 +05:30
Kailasdevdas 2e63106439 Merge branch 'feat/seo-preview' into feat/healthcheck-seo 2026-05-26 11:59:23 +05:30
rishalkv c2b54725fe fix: og image update 2026-05-26 11:57:10 +05:30
Kailasdevdas 4d73da5ddd feat: health check seo 2026-05-26 11:56:22 +05:30
rishalkv fa06126219 chore: add seo reusable component 2026-05-26 11:38:34 +05:30
rishalkv fc491f4050 feat: seo preview 2026-05-25 16:20:47 +05:30
kailasdevdas 31c0e50177 Merge pull request 'fix: handle empty package pricing fields correctly' (#42) from fix/optional-pricing into dev
Reviewed-on: #42
2026-05-25 07:37:12 +00:00
Kailasdevdas 8f813ed7c4 fix: handle empty package pricing fields correctly 2026-05-25 12:59:44 +05:30
kailasdevdas 9a14965a54 Merge pull request 'fix: remove duplicate toasts' (#41) from fix/optional-pricing into dev
Reviewed-on: #41
2026-05-25 07:00:27 +00:00
kailasdevdas 2fc57a1ae9 Merge pull request 'feat: add dynamic slug' (#40) from feat/dynamic-slug into dev
Reviewed-on: #40
2026-05-25 06:57:42 +00:00
Kailasdevdas d76011d301 fix: remove duplicate toasts 2026-05-25 12:19:09 +05:30
rishalkv 6d5e243e06 feat: add dynamic slug 2026-05-25 12:04:14 +05:30
kailasdevdas 9210621d67 Merge pull request 'fix: optional price fields' (#39) from fix/optional-pricing into dev
Reviewed-on: #39
2026-05-25 06:11:31 +00:00
Kailasdevdas cefaf3a850 fix: optional price fields 2026-05-25 11:37:51 +05:30
kailasdevdas 120ff12fef Merge pull request 'fix: add toggle action update controller' (#38) from fix/doc-update-controller into dev
Reviewed-on: #38
2026-05-22 11:40:52 +00:00
rishalkv 12d9f2a4cb fix: add toggle action update controller 2026-05-22 16:34:37 +05:30
kailasdevdas 5eecc5092d Merge pull request 'fix: use migrate deploy' (#37) from fix/prisma-migrate into dev
Reviewed-on: #37
2026-05-22 09:20:03 +00:00
Kailasdevdas f11c8ae8dc fix: use migrate deploy 2026-05-22 14:21:25 +05:30
kailasdevdas 558ab12e1f Merge pull request 'fix: bytescale type' (#36) from fix/bytescale-type into dev
Reviewed-on: #36
2026-05-21 09:12:43 +00:00
rishalkv 0f839c7f84 fix: bytescale type 2026-05-21 14:36:17 +05:30
kailasdevdas 9271ea9b38 Merge pull request 'feat:add seo and more about doctors' (#34) from feat/doc-seo-content-enhacement into dev
Reviewed-on: #34
2026-05-21 08:47:32 +00:00
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
37 changed files with 4204 additions and 105 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;
@@ -0,0 +1,14 @@
/*
Warnings:
- A unique constraint covering the columns `[seoId]` on the table `HealthPackage` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "HealthPackage" ADD COLUMN "seoId" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "HealthPackage_seoId_key" ON "HealthPackage"("seoId");
-- AddForeignKey
ALTER TABLE "HealthPackage" ADD CONSTRAINT "HealthPackage_seoId_fkey" FOREIGN KEY ("seoId") REFERENCES "Seo"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+95 -1
View File
@@ -23,11 +23,15 @@ model Doctor {
name String
image String?
designation String?
experience Int?
workingStatus String?
qualification String?
isActive Boolean @default(true)
globalSortOrder Int @default(1000)
specializations DoctorSpecialization[]
professionalSummary String? @db.Text
seoId Int? @unique
seo Seo? @relation(fields: [seoId], references: [id])
departments DoctorDepartment[]
appointments Appointment[]
@@ -220,3 +224,93 @@ model NewsImage {
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[]
seoId Int? @unique
seo Seo? @relation(fields: [seoId], references: [id])
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?
healthPackage HealthPackage?
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 newsMediaRoutes from "./routes/newsMedia.routes.js";
import importRoutes from "./routes/importRoutes.js";
import healthCheckRoutes from "./routes/healthCheck.route.js";
dotenv.config();
@@ -55,6 +56,7 @@ app.use("/api/academics", academicsResearchRoutes);
app.use("/api/email", emailConfigRoutes);
app.use("/api/newsMedia", newsMediaRoutes);
app.use("/api/import", importRoutes);
app.use("/api/health-check", healthCheckRoutes);
const PORT = process.env.PORT || 5008;
app.listen(PORT, () => {
@@ -84,8 +84,12 @@ export const createAppointment = async (req, res) => {
<tr>
<td style="padding: 8px 0;"><b>Date:</b></td>
<td style="padding: 8px 0;">
${new Date(date).toLocaleDateString()}
</td>
${new Date(date).toLocaleDateString("en-GB", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</td>
</tr>
</table>
@@ -143,18 +147,51 @@ export const getAppointments = async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
const { date, search } = req.query;
const { date, startDate, endDate, search } = req.query;
const where = {};
if (date) {
const hasSingleDate = date && date.trim() !== "";
const hasRange =
(startDate && startDate.trim() !== "") ||
(endDate && endDate.trim() !== "");
if (hasSingleDate) {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
const end = new Date(date);
end.setDate(end.getDate() + 1);
where.date = { gte: start, lt: end };
end.setHours(23, 59, 59, 999);
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 = [
{ name: { contains: search, mode: "insensitive" } },
{ mobileNumber: { contains: search } },
@@ -165,24 +202,39 @@ export const getAppointments = async (req, res) => {
const [appointments, total] = await Promise.all([
prisma.appointment.findMany({
where,
include: { doctor: true, department: true },
orderBy: { createdAt: "desc" },
include: {
doctor: true,
department: true,
},
orderBy: {
createdAt: "desc",
},
skip,
take: limit,
}),
prisma.appointment.count({ where }),
prisma.appointment.count({
where,
}),
]);
res.status(200).json({
success: true,
data: appointments,
pagination: { total, page, limit, totalPages: Math.ceil(total / limit) },
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error(error);
res
.status(500)
.json({ success: false, message: "Failed to fetch appointments" });
res.status(500).json({
success: false,
message: "Failed to fetch appointments",
});
}
};
+274 -31
View File
@@ -9,12 +9,18 @@ export const getAllDoctors = async (req, res) => {
const doctors = await prisma.doctor.findMany({
where: admin === "true" ? {} : {isActive: true},
include: {
seo: true,
departments: {
include: {
department: true,
timing: true,
},
},
specializations: {
orderBy: {
createdAt: "asc",
},
},
},
orderBy: [{globalSortOrder: "asc"}, {name: "asc"}],
});
@@ -28,7 +34,25 @@ export const getAllDoctors = async (req, res) => {
workingStatus: doc.workingStatus,
qualification: doc.qualification,
isActive: doc.isActive,
experience: doc.experience,
professionalSummary: doc.professionalSummary,
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) => {
const t = d.timing || {};
const timingArray = [
@@ -73,6 +97,8 @@ export const getDoctorByDoctorId = async (req, res) => {
const doctor = await prisma.doctor.findUnique({
where: {doctorId},
include: {
seo: true,
specializations: true,
departments: {
include: {
department: true,
@@ -96,6 +122,24 @@ export const getDoctorByDoctorId = async (req, res) => {
designation: doctor.designation,
workingStatus: doctor.workingStatus,
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) => ({
departmentId: d.department.departmentId,
departmentName: d.department.name,
@@ -145,7 +189,15 @@ export const getDoctorsByDepartmentId = async (req, res) => {
doctor: {isActive: true},
},
include: {
doctor: true,
doctor: {
include: {
seo: {
select: {
slug: true,
},
},
},
},
},
orderBy: {sortOrder: "asc"},
});
@@ -156,6 +208,7 @@ export const getDoctorsByDepartmentId = async (req, res) => {
image: d.doctor.image ?? "",
designation: d.doctor.designation,
hierarchyOrder: d.sortOrder,
slug: d.doctor.seo?.slug ?? "",
}));
res.status(200).json({
@@ -184,7 +237,48 @@ export const createDoctor = async (req, res) => {
isActive,
globalSortOrder,
departments,
experience,
professionalSummary,
seoTitle,
metaDescription,
focusKeyphrase,
slug,
tags,
specializations,
ogTitle,
ogDescription,
ogImage,
} = 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({
data: {
@@ -194,6 +288,9 @@ export const createDoctor = async (req, res) => {
designation,
workingStatus,
qualification,
experience: experience ? Number(experience) : null,
professionalSummary,
seoId: seo.id,
isActive: isActive !== undefined ? isActive : true,
globalSortOrder:
globalSortOrder !== undefined ? Number(globalSortOrder) : 0,
@@ -224,6 +321,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({
success: true,
@@ -241,7 +349,7 @@ export const createDoctor = async (req, res) => {
//update doctors
export const updateDoctor = async (req, res) => {
try {
const {doctorId} = req.params;
const {doctorId, action} = req.params;
const {
name,
designation,
@@ -251,13 +359,62 @@ export const updateDoctor = async (req, res) => {
isActive,
globalSortOrder,
departments,
experience,
professionalSummary,
seoTitle,
metaDescription,
ogTitle,
ogDescription,
focusKeyphrase,
slug,
tags,
ogImage,
specializations,
} = req.body;
if (!doctorId) {
return res.status(400).json({
success: false,
message: "Doctor ID is required",
});
}
const doctor = await prisma.doctor.findUnique({where: {doctorId}});
if (!doctor)
return res
.status(404)
.json({success: false, message: "Doctor not found"});
if (action === "toggleStatus") {
await prisma.doctor.update({
where: {id: doctor.id},
data: {
isActive: !doctor.isActive,
},
});
return res.status(200).json({
success: true,
message: `Doctor has been ${
doctor.isActive ? "deactivated" : "activated"
} successfully`,
});
}
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(", "),
});
}
await prisma.doctor.update({
where: {id: doctor.id},
@@ -268,45 +425,131 @@ export const updateDoctor = async (req, res) => {
workingStatus,
qualification,
isActive,
experience: experience ? Number(experience) : null,
professionalSummary,
globalSortOrder:
globalSortOrder !== undefined ? Number(globalSortOrder) : undefined,
},
});
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 targetDept = await prisma.department.findUnique({
where: {departmentId: dep.departmentId},
});
if (!targetDept) continue;
const newDD = await prisma.doctorDepartment.create({
if (doctor.seoId) {
await prisma.seo.update({
where: {
id: doctor.seoId,
},
data: {
doctorId: doctor.id,
departmentId: targetDept.id,
sortOrder: dep.sortOrder !== undefined ? Number(dep.sortOrder) : 0,
seoTitle,
metaDescription,
ogTitle,
ogDescription,
ogImage,
focusKeyphrase,
slug: slug ? slug : null,
tags: tags || [],
},
});
} else {
const seo = await prisma.seo.create({
data: {
ogImage,
metaDescription,
seoTitle,
ogDescription,
ogTitle,
focusKeyphrase,
slug: slug ? slug : null,
tags: tags || [],
},
});
if (dep.timing) {
const {id, doctorDepartmentId, createdAt, updatedAt, ...cleanTiming} =
dep.timing;
await prisma.doctor.update({
where: {
id: doctor.id,
},
data: {
seoId: seo.id,
},
});
}
await prisma.doctorTiming.create({
data: {doctorDepartmentId: newDD.id, ...cleanTiming},
// Update Departments & Timings
if (Array.isArray(departments)) {
const oldRelations = await prisma.doctorDepartment.findMany({
where: {
doctorId: doctor.id,
},
include: {
timing: true,
},
});
// Delete old timings
for (const rel of oldRelations) {
if (rel.timing) {
await prisma.doctorTiming.deleteMany({
where: {
doctorDepartmentId: rel.id,
},
});
}
}
// Delete old departments
await prisma.doctorDepartment.deleteMany({
where: {
doctorId: doctor.id,
},
});
// Recreate departments + timings
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,
sortOrder: dep.sortOrder !== undefined ? Number(dep.sortOrder) : 0,
},
});
if (dep.timing && Object.keys(dep.timing).length > 0) {
const {id, doctorDepartmentId, createdAt, updatedAt, ...cleanTiming} =
dep.timing;
await prisma.doctorTiming.create({
data: {
doctorDepartmentId: doctorDepartment.id,
...cleanTiming,
},
});
}
}
}
// 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,
})),
});
}
}
@@ -0,0 +1,527 @@
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,
seo: 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,
seo,
} = 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,
...(seo && {
seo: {
create: {
seoTitle: seo.seoTitle,
metaDescription: seo.metaDescription,
focusKeyphrase: seo.focusKeyphrase,
slug: slug,
tags: seo.tags || [],
ogTitle: seo.ogTitle,
ogDescription: seo.ogDescription,
ogImage: seo.ogImage,
},
},
}),
},
include: {
category: true,
seo: true,
},
});
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;
delete data.createdAt;
delete data.updatedAt;
delete data.seoId;
if (data.categoryId) data.categoryId = Number(data.categoryId);
if (data.sortOrder) data.sortOrder = Number(data.sortOrder);
const existingPackage = await prisma.healthPackage.findUnique({
where: { id: Number(id) },
select: { slug: true },
});
const seoSlug = data.slug || existingPackage.slug;
const updated = await prisma.healthPackage.update({
where: { id: Number(id) },
data: {
...data,
seo: data.seo
? {
upsert: {
create: {
seoTitle: data.seo.seoTitle,
metaDescription: data.seo.metaDescription,
focusKeyphrase: data.seo.focusKeyphrase,
slug: seoSlug,
tags: data.seo.tags || [],
ogTitle: data.seo.ogTitle,
ogDescription: data.seo.ogDescription,
ogImage: data.seo.ogImage,
},
update: {
seoTitle: data.seo.seoTitle,
metaDescription: data.seo.metaDescription,
focusKeyphrase: data.seo.focusKeyphrase,
slug: seoSlug,
tags: data.seo.tags || [],
ogTitle: data.seo.ogTitle,
ogDescription: data.seo.ogDescription,
ogImage: data.seo.ogImage,
},
},
}
: undefined,
},
include: {
category: true,
seo: true,
},
});
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,
seo: 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" });
}
};
+1 -1
View File
@@ -21,7 +21,7 @@ router.get("/getTimings/:doctorId", getDoctorTimingById);
router.get("/:doctorId", getDoctorByDoctorId);
router.post("/", jwtAuthMiddleware, createDoctor);
router.patch("/:doctorId", jwtAuthMiddleware, updateDoctor);
router.patch("/:doctorId/:action", jwtAuthMiddleware, updateDoctor);
router.delete("/:doctorId", jwtAuthMiddleware, deleteDoctor);
export default router;
+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 -4
View File
@@ -4,10 +4,8 @@ set -e # Exit immediately if a command exits with a non-zero status
echo "Generating Prisma Client..."
npx prisma generate
# echo "Running migrate..."
# npx prisma migrate deploy
echo "Running PUSH..."
npx prisma db push
echo "Running migrate..."
npx prisma migrate deploy
echo "Executing command: $@"
exec "$@"
+2
View File
@@ -23,6 +23,7 @@ import AcademicsPage from "./pages/Academics";
import NewsPage from "./pages/newsMedia";
import BlogDetail from "./pages/BlogDetails";
import ImportData from "./pages/ImportData";
import HealthPackagePage from "./pages/HealthPackagePage";
export default function App() {
return (
@@ -51,6 +52,7 @@ export default function App() {
<Route path="/academics" element={<AcademicsPage />} />
<Route path="/news" element={<NewsPage />} />
<Route path="/import" element={<ImportData />} />
<Route path="/health-check" element={<HealthPackagePage />} />
</Route>
</Route>
+4
View File
@@ -4,12 +4,16 @@ export const getAppointmentsApi = async (
page = 1,
limit = 10,
date = "",
startDate = "",
endDate = "",
search = "",
) => {
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
...(date && { date }),
...(startDate && { startDate }),
...(endDate && { endDate }),
...(search && { search }),
});
const res = await apiClient.get(`/appointments/getall?${params}`);
+5 -2
View File
@@ -8,8 +8,10 @@ export interface Doctor {
designation?: string;
workingStatus?: string;
qualification?: string;
isActive: boolean;
globalSortOrder: number;
departments: {
departments?: {
departmentId: string;
timing?: {
monday?: string;
@@ -51,9 +53,10 @@ export const createDoctorApi = async (data: Doctor) => {
export const updateDoctorApi = async (
doctorId: string,
data: Partial<Doctor>,
action: "toggleStatus" | "updateDetails" = "updateDetails",
) => {
try {
const res = await apiClient.patch(`/doctors/${doctorId}`, data);
const res = await apiClient.patch(`/doctors/${doctorId}/${action}`, data);
toast.success("Doctor updated successfully");
+170
View File
@@ -0,0 +1,170 @@
import apiClient from "@/api/client";
import toast from "react-hot-toast";
export interface SeoData {
seoTitle?: string;
metaDescription?: string;
focusKeyphrase?: string;
tags?: string[];
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
}
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;
seo?: SeoData | null;
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,19 @@
import {useState, useRef} from "react";
import {Button} from "@/components/ui/button";
import {User, X, Loader2} from "lucide-react";
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" | "/blog";
folderPath:
| "/health-packages"
| "/seo"
| "/doctors"
| "/departments"
| "/news"
| "/blog"
| "/doctor-og";
}
export function BytescaleUploader({
@@ -40,7 +47,7 @@ export function BytescaleUploader({
},
});
const {fileUrl} = response.data;
const { fileUrl } = response.data;
onChange(fileUrl);
} catch (e: any) {
console.error("Upload Error:", e);
@@ -0,0 +1,436 @@
import { BytescaleUploader } from "@/components/BytescaleUploader/BytescaleUploader";
import SeoFields from "@/components/SeoFields/SeoFields";
import { useEffect } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Plus, Trash2 } from "lucide-react";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
editingPackage: any;
pkgForm: any;
setPkgForm: any;
inclusionsList: any[];
setInclusionsList: any;
categories: any[];
onSave: () => void;
}
export default function HealthPackageModal({
open,
onOpenChange,
editingPackage,
pkgForm,
setPkgForm,
inclusionsList,
setInclusionsList,
categories,
onSave,
}: Props) {
useEffect(() => {
if (!editingPackage && pkgForm.name) {
setPkgForm((prev: any) => ({
...prev,
slug: prev.slug
? prev.slug
: pkgForm.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, ""),
}));
}
}, [pkgForm.name]);
const handleAddInclusionField = () => {
setInclusionsList([
...inclusionsList,
{
id: Date.now(),
category: "",
items: "",
},
]);
};
const handleRemoveInclusionField = (id: number) => {
setInclusionsList(inclusionsList.filter((item) => item.id !== id));
};
const handleUpdateInclusionField = (
id: number,
field: string,
value: string,
) => {
setInclusionsList(
inclusionsList.map((item) =>
item.id === id
? {
...item,
[field]: value,
}
: item,
),
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full !max-w-7xl h-[92vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="px-6 py-5 border-b bg-background sticky top-0 z-20">
<DialogTitle className="text-2xl font-bold">
{editingPackage ? "Edit Health Package" : "Create Health Package"}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 xl:grid-cols-[1.2fr_0.8fr] gap-8 p-6">
{/* LEFT COLUMN */}
<div className="space-y-8">
<div className="space-y-5">
<div className="sticky top-0 bg-background z-10 pb-2">
<h3 className="text-lg font-bold">Profile & Pricing</h3>
<p className="text-sm text-muted-foreground">
Main package information
</p>
</div>
<div className="space-y-5">
<div className="space-y-2">
<Label className="font-semibold">Package Image</Label>
<p className="text-xs text-muted-foreground">
Recommended size: 650 × 250
</p>
<BytescaleUploader
value={pkgForm.image || ""}
folderPath="/health-packages"
onChange={(url) =>
setPkgForm({
...pkgForm,
image: url,
})
}
/>
</div>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Active Visibility</p>
<p className="text-sm text-muted-foreground">
Show this package publicly
</p>
</div>
<Switch
checked={pkgForm.isActive}
onCheckedChange={(val) =>
setPkgForm({
...pkgForm,
isActive: val,
})
}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="font-semibold">Package Name</Label>
<Input
value={pkgForm.name}
onChange={(e) =>
setPkgForm({
...pkgForm,
name: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">URL Slug</Label>
<Input
value={pkgForm.slug}
onChange={(e) =>
setPkgForm({
...pkgForm,
slug: e.target.value,
})
}
/>
</div>
</div>
<div className="space-y-2">
<Label className="font-semibold">Category</Label>
<Select
value={pkgForm.categoryId?.toString()}
onValueChange={(v) =>
setPkgForm({
...pkgForm,
categoryId: Number(v),
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.id} value={c.id.toString()}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="font-semibold">Regular Price ()</Label>
<Input
type="number"
value={pkgForm.price || ""}
onChange={(e) => {
const value = e.target.value
? Number(e.target.value)
: undefined;
setPkgForm({
...pkgForm,
price: value,
});
}}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">
Discounted Price ()
</Label>
<Input
type="number"
disabled={!pkgForm.price}
value={pkgForm.discountedPrice || ""}
onChange={(e) =>
setPkgForm({
...pkgForm,
discountedPrice: e.target.value
? Number(e.target.value)
: undefined,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">Sort Priority</Label>
<Input
type="number"
value={pkgForm.sortOrder}
onChange={(e) =>
setPkgForm({
...pkgForm,
sortOrder: Number(e.target.value),
})
}
/>
</div>
</div>
<div className="space-y-2">
<Label className="font-semibold">Description</Label>
<Textarea
rows={5}
value={pkgForm.description}
onChange={(e) =>
setPkgForm({
...pkgForm,
description: e.target.value,
})
}
/>
</div>
</div>
</div>
{/* INCLUSIONS */}
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-bold">Tests & Inclusions</h3>
<p className="text-sm text-muted-foreground">
Group tests into categories
</p>
</div>
<Badge variant="outline">
{inclusionsList.length} Groups
</Badge>
</div>
<Accordion type="multiple" className="space-y-4">
{inclusionsList.map((inc, index) => {
const testCount = inc.items
?.split(",")
.filter(Boolean).length;
return (
<AccordionItem
key={inc.id}
value={inc.id.toString()}
className="border rounded-xl bg-background px-5 shadow-sm"
>
<AccordionTrigger className="hover:no-underline w-full">
<div className="flex w-full items-center justify-between">
<div className="flex flex-col items-start text-left">
<p className="font-semibold">
{inc.category || `Group ${index + 1}`}
</p>
<p className="text-xs text-muted-foreground">
{testCount || 0} tests included
</p>
</div>
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-600"
onClick={() => handleRemoveInclusionField(inc.id)}
>
<Trash2 className="h-4 w-4 mr-1" />
Remove
</Button>
</div>
</AccordionTrigger>
<AccordionContent className="pt-4">
<div className="space-y-4">
<div className="space-y-2">
<Label>Category Title</Label>
<Input
placeholder="Routine Blood Tests"
value={inc.category}
onChange={(e) =>
handleUpdateInclusionField(
inc.id,
"category",
e.target.value,
)
}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Included Tests</Label>
</div>
<Textarea
rows={4}
placeholder="CBC, LFT, RFT, TSH"
value={inc.items}
onChange={(e) =>
handleUpdateInclusionField(
inc.id,
"items",
e.target.value,
)
}
/>
<p className="text-xs text-muted-foreground">
Separate each test using commas
</p>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
<Button
variant="outline"
className="w-full border-dashed border-2 h-12"
onClick={handleAddInclusionField}
>
<Plus className="h-4 w-4 mr-2" />
Add New Inclusion Group
</Button>
</div>
</div>
{/* RIGHT COLUMN */}
<div className="space-y-6">
<SeoFields
value={pkgForm.seo}
slug={pkgForm.slug}
folderPath="/seo"
onChange={(seo) =>
setPkgForm({
...pkgForm,
seo,
})
}
/>
</div>
</div>
</div>
<DialogFooter className="p-6 border-t bg-background sticky bottom-0 z-20">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button className="px-10" onClick={onSave}>
{editingPackage ? "Save Changes" : "Create Package"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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>
);
}
@@ -0,0 +1,211 @@
import { BytescaleUploader } from "@/components/BytescaleUploader/BytescaleUploader";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { X } from "lucide-react";
interface SeoData {
seoTitle?: string;
metaDescription?: string;
focusKeyphrase?: string;
tags?: string[];
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
}
interface SeoFieldsProps {
value?: SeoData;
onChange: (seo: SeoData) => void;
slug?: string;
folderPath?: "/seo";
}
export default function SeoFields({
value,
onChange,
slug,
folderPath = "/seo",
}: SeoFieldsProps) {
const seo = value || {};
const updateSeo = (field: keyof SeoData, fieldValue: any) => {
onChange({
...seo,
[field]: fieldValue,
});
};
const removeTag = (index: number) => {
updateSeo(
"tags",
(seo.tags || []).filter((_, i) => i !== index),
);
};
return (
<div className="space-y-5 p-5 border rounded-xl bg-muted/20">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-bold">SEO Settings</h3>
<p className="text-sm text-muted-foreground">
Optimize for Google & social sharing
</p>
</div>
<Badge variant="secondary">Optional</Badge>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold">SEO Title</Label>
<span className="text-xs text-muted-foreground">
{seo.seoTitle?.length || 0}/60
</span>
</div>
<Input
placeholder="Best Health Checkup Package in Kochi"
value={seo.seoTitle || ""}
onChange={(e) => updateSeo("seoTitle", e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Recommended: 5060 characters
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold">Meta Description</Label>
<span className="text-xs text-muted-foreground">
{seo.metaDescription?.length || 0}/160
</span>
</div>
<Textarea
rows={4}
placeholder="Short description shown in Google search results"
value={seo.metaDescription || ""}
onChange={(e) => updateSeo("metaDescription", e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Recommended: 150160 characters
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Focus Keyphrase</Label>
<Input
placeholder="health checkup package kochi"
value={seo.focusKeyphrase || ""}
onChange={(e) => updateSeo("focusKeyphrase", e.target.value)}
/>
</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">
{(seo.tags || []).map((tag, index) => (
<div
key={index}
className="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm flex items-center gap-2"
>
<span>{tag}</span>
<button
type="button"
onClick={() => removeTag(index)}
className="hover:text-red-500 transition-colors"
>
<X className="h-3 w-3" />
</button>
</div>
))}
<Input
placeholder="Type keyword and press Enter"
className="border-0 shadow-none focus-visible:ring-0 min-w-[220px] flex-1"
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
e.preventDefault();
const newTag = e.currentTarget.value.trim();
if (!(seo.tags || []).includes(newTag)) {
updateSeo("tags", [...(seo.tags || []), newTag]);
}
e.currentTarget.value = "";
}
}}
/>
</div>
<p className="text-xs text-muted-foreground">Press Enter to add tags</p>
</div>
<div className="border-t pt-5 space-y-5">
<div className="flex items-center justify-between">
<div>
<h4 className="font-bold">Open Graph (Social Preview)</h4>
<p className="text-sm text-muted-foreground">
Facebook, WhatsApp & Twitter sharing
</p>
</div>
<Badge variant="secondary">Optional</Badge>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">OG Title</Label>
<Input
placeholder="Title for social sharing"
value={seo.ogTitle || ""}
onChange={(e) => updateSeo("ogTitle", e.target.value)}
/>
<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
rows={4}
placeholder="Description for social sharing"
value={seo.ogDescription || ""}
onChange={(e) => updateSeo("ogDescription", e.target.value)}
/>
<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={seo.ogImage || ""}
folderPath={folderPath}
onChange={(url) => updateSeo("ogImage", url)}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,158 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface SeoPreviewData {
seo?: {
ogImage?: string;
ogTitle?: string;
seoTitle?: string;
ogDescription?: string;
metaDescription?: string;
slug?: string;
};
doctorId?: string;
name?: string;
}
interface SeoPreviewProps {
open: boolean;
onOpenChange: (open: boolean) => void;
previewData?: SeoPreviewData | null;
url?: string;
title?: string;
}
export default function SeoPreview({
open,
onOpenChange,
previewData,
url,
title = "SEO Preview",
}: SeoPreviewProps) {
const previewUrl = url || "https://www.gg-hospital.com";
const hasSeoData =
!!previewData?.seo &&
!!(
previewData.seo.ogImage ||
previewData.seo.ogTitle ||
previewData.seo.seoTitle ||
previewData.seo.ogDescription ||
previewData.seo.metaDescription
);
const imageUrl =
previewData?.seo?.ogImage ||
"https://placehold.co/1200x630?text=GG+Hospital";
const ogTitle =
previewData?.seo?.ogTitle || previewData?.seo?.seoTitle || "GG Hospital";
const ogDescription =
previewData?.seo?.ogDescription ||
previewData?.seo?.metaDescription ||
"No description available";
const searchTitle =
previewData?.seo?.seoTitle ||
previewData?.seo?.ogTitle ||
"SEO title preview";
const searchDescription =
previewData?.seo?.metaDescription ||
previewData?.seo?.ogDescription ||
"No meta description available";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:!max-w-4xl overflow-hidden">
<DialogHeader>
<DialogTitle className="text-xl">{title}</DialogTitle>
</DialogHeader>
{hasSeoData ? (
<div className="space-y-10 py-2">
{/* Social Preview */}
<div>
<p className="mb-4 text-sm font-semibold text-muted-foreground">
Social Media Preview (WhatsApp / Facebook)
</p>
<a
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="block max-w-[560px] overflow-hidden rounded-xl border bg-white shadow-sm transition hover:shadow-md"
>
<div className="aspect-[1.91/1] overflow-hidden bg-muted">
<img
src={imageUrl}
alt="OG Preview"
className="h-full w-full object-cover"
/>
</div>
<div className="border-t bg-[#f0f2f5] px-4 py-3">
<h3 className="mt-1 line-clamp-2 text-[18px] font-semibold leading-snug text-[#1c1e21]">
{ogTitle}
</h3>
<p className="mt-1 line-clamp-2 text-[14px] text-[#65676b]">
{ogDescription}
</p>
<p className="mt-1 truncate text-[11px] tracking-wide text-[#65676b]">
{previewUrl}
</p>
</div>
</a>
</div>
{/* Google Preview */}
<div>
<p className="mb-4 text-sm font-semibold text-muted-foreground">
Google Search Preview
</p>
<div className="rounded-xl border bg-white p-6">
<a
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<p className="truncate text-[14px] text-[#202124] hover:underline">
{previewUrl}
</p>
<h3 className="mt-1 text-[22px] leading-tight text-[#1a0dab] hover:underline">
{searchTitle}
</h3>
</a>
<p className="mt-2 line-clamp-3 text-[14px] leading-6 text-[#4d5156]">
{searchDescription}
</p>
</div>
</div>
</div>
) : (
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
No preview data available.
</div>
)}
<DialogFooter className="mt-0 border-t bg-background p-6">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+6 -1
View File
@@ -15,6 +15,10 @@ export default function Sidebar() {
name: "Doctor",
path: "/doctor",
},
{
name: "Health Check",
path: "/health-check",
},
{
name: "Appointments",
path: "/appointment",
@@ -65,7 +69,8 @@ export default function Sidebar() {
<Link key={item.path} to={item.path}>
<Button
variant={active ? "secondary" : "ghost"}
className="w-full justify-start">
className="w-full justify-start"
>
{item.name}
</Button>
</Link>
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+2 -2
View File
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
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: {
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",
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",
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-xs":
"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
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",
"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
)}
{...props}
@@ -127,7 +127,10 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
className={cn(
"text-base leading-none font-medium",
className
)}
{...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 }
+84 -33
View File
@@ -40,7 +40,8 @@ export default function AppointmentPage() {
const [searchText, setSearchText] = useState("");
const [filterDoctor, setFilterDoctor] = useState("");
const [filterDate, setFilterDate] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [viewOpen, setViewOpen] = useState(false);
const [viewData, setViewData] = useState<any>(null);
@@ -56,6 +57,8 @@ export default function AppointmentPage() {
currentPage,
itemsPerPage,
filterDate,
startDate,
endDate,
searchText,
);
setAppointments(res?.data || []);
@@ -66,7 +69,7 @@ export default function AppointmentPage() {
} finally {
setLoading(false);
}
}, [currentPage, itemsPerPage, filterDate, searchText]);
}, [currentPage, itemsPerPage, filterDate, startDate, endDate, searchText]);
useEffect(() => {
fetchAll();
@@ -116,39 +119,87 @@ export default function AppointmentPage() {
<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);
setCurrentPage(1);
}}
className="w-[220px] text-base"
/>
<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
placeholder="Search name / phone..."
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
setCurrentPage(1);
}}
className="w-[220px] text-base"
/>
</div>
<Input
type="date"
value={filterDate}
onChange={(e) => {
setFilterDate(e.target.value);
setCurrentPage(1);
}}
className="w-[160px] text-base"
/>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Date
</label>
<Input
type="date"
value={filterDate}
onChange={(e) => {
setFilterDate(e.target.value);
setCurrentPage(1);
}}
className="w-[160px] text-base"
disabled={!!startDate || !!endDate}
/>
</div>
<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>
<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
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>
</div>
<Button
variant="outline"
+36 -5
View File
@@ -10,6 +10,7 @@ import Table from "@editorjs/table";
import CodeTool from "@editorjs/code";
import Embed from "@editorjs/embed";
import Delimiter from "@editorjs/delimiter";
import axios from "axios";
import {
createBlogApi,
@@ -23,6 +24,7 @@ import {Input} from "@/components/ui/input";
import {Button} from "@/components/ui/button";
export default function BlogEditorPage() {
const baseURL = import.meta.env.VITE_API_URL;
const {id} = useParams();
const navigate = useNavigate();
@@ -79,12 +81,41 @@ export default function BlogEditorPage() {
config: {
uploader: {
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: ""}};
}
return {
success: 1,
file: {url: res.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 {
success: 1,
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: ""},
};
}
},
},
},
+458 -4
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { AxiosError } from "axios";
import { Eye } from "lucide-react";
import { BytescaleUploader } from "@/components/BytescaleUploader/BytescaleUploader";
import {
@@ -28,6 +28,7 @@ import {
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import SeoPreview from "@/components/SeoPreview/SeoPreview";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
@@ -41,6 +42,7 @@ import {
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
interface Department {
departmentId: string;
@@ -59,6 +61,8 @@ const DAYS = [
];
export default function DoctorPage() {
const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL;
const [doctors, setDoctors] = useState<any[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [loading, setLoading] = useState(true);
@@ -83,7 +87,24 @@ export default function DoctorPage() {
isActive: true,
globalSortOrder: 0,
departments: [],
professionalSummary: "",
seoTitle: "",
metaDescription: "",
ogTitle: "",
ogDescription: "",
ogImage: "",
specializations: [
{
name: "",
description: "",
},
],
focusKeyphrase: "",
slug: "",
tags: [],
});
const [openOgPreview, setOpenOgPreview] = useState(false);
const [previewDoctor, setPreviewDoctor] = useState<any>(null);
const fetchAll = useCallback(async () => {
setLoading(true);
@@ -151,15 +172,29 @@ export default function DoctorPage() {
const currentItems = filteredDoctors.slice(indexOfFirstItem, indexOfLastItem);
function handleChange(e: any) {
const value =
let 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 });
}
const handleToggleStatus = async (doc: any) => {
try {
const updatedDoc = { ...doc, isActive: !doc.isActive };
await updateDoctorApi(doc.doctorId, updatedDoc);
const newStatus = !doc.isActive;
const payload = {
isActive: newStatus,
};
await updateDoctorApi(doc.doctorId, payload, "toggleStatus");
fetchAll();
} catch (err) {
console.error("Failed to update status", err);
@@ -215,9 +250,25 @@ export default function DoctorPage() {
designation: "",
workingStatus: "",
qualification: "",
experience: "",
professionalSummary: "",
isActive: true,
globalSortOrder: 0,
specializations: [
{
name: "",
description: "",
},
],
departments: [],
seoTitle: "",
metaDescription: "",
focusKeyphrase: "",
slug: "",
tags: [],
ogTitle: "",
ogDescription: "",
ogImage: "",
});
setOpenModal(true);
}
@@ -237,6 +288,27 @@ export default function DoctorPage() {
qualification: doc.qualification,
isActive: doc.isActive ?? true,
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) => ({
departmentId: d.departmentId,
sortOrder: d.deptSortOrder ?? 0,
@@ -249,6 +321,11 @@ export default function DoctorPage() {
}
}
function handlePreview(doc: any) {
setPreviewDoctor(doc);
setOpenOgPreview(true);
}
async function handleSubmit() {
try {
if (editing) {
@@ -263,6 +340,24 @@ export default function DoctorPage() {
}
}
const createSlug = (text: string) => {
if (!text) return "";
return text
.toString()
.toLowerCase()
.trim()
.replace(/\s+/g, "-")
.replace(/[^\w-]+/g, "")
.replace(/--+/g, "-");
};
const getDoctorUrl = (doctor: any) => {
const slug = doctor?.seo?.slug || createSlug(doctor?.name);
return `${WEBSITE_URL}/${doctor?.doctorId}/${slug}`;
};
return (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
@@ -423,6 +518,14 @@ export default function DoctorPage() {
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={() => handlePreview(doc)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
@@ -588,6 +691,24 @@ export default function DoctorPage() {
className="text-base"
/>
</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">
<label className="text-sm font-semibold">
Qualification
@@ -600,6 +721,25 @@ export default function DoctorPage() {
className="text-base"
/>
</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 className="p-5 border rounded-md bg-muted/20">
@@ -626,6 +766,314 @@ export default function DoctorPage() {
})}
</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 className="space-y-6">
@@ -712,6 +1160,12 @@ export default function DoctorPage() {
</DialogFooter>
</DialogContent>
</Dialog>
<SeoPreview
open={openOgPreview}
onOpenChange={setOpenOgPreview}
previewData={previewDoctor}
url={getDoctorUrl(previewDoctor)}
/>
</div>
);
}
+785
View File
@@ -0,0 +1,785 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import toast from "react-hot-toast";
import { AxiosError } from "axios";
import {
getHealthPackagesApi,
getHealthCategoriesApi,
updateHealthPackageApi,
createHealthPackageApi,
createCategoryApi,
updateCategoryApi,
HealthPackage,
HealthCategory,
} from "@/api/healthCheck";
import PackageInquiriesTab from "@/components/PackageInquiriesTab/PackageInquiriesTab";
import HealthPackageModal from "@/components/HealthPackageModal/HealthPackageModal";
import SeoPreview from "@/components/SeoPreview/SeoPreview";
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 { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Loader2,
RefreshCw,
Plus,
Pencil,
ChevronLeft,
ChevronRight,
LayoutGrid,
Eye,
} from "lucide-react";
export default function HealthPackagePage() {
const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL;
const [packages, setPackages] = useState<HealthPackage[]>([]);
const [categories, setCategories] = useState<HealthCategory[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// Modals
const [packageModal, setPackageModal] = useState(false);
const [categoryModal, setCategoryModal] = useState(false);
const [viewModal, setViewModal] = useState(false);
// States
const [selectedPackage, setSelectedPackage] = useState<HealthPackage | null>(
null,
);
const [editingPackage, setEditingPackage] = useState<HealthPackage | null>(
null,
);
const [editingCategory, setEditingCategory] = useState<HealthCategory | null>(
null,
);
// Filters & Pagination
const [searchText, setSearchText] = useState("");
const [filterCategory, setFilterCategory] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
// Forms
const [pkgForm, setPkgForm] = useState<Partial<HealthPackage>>({
name: "",
slug: "",
description: "",
image: "",
price: undefined,
discountedPrice: undefined,
categoryId: 0,
isActive: true,
sortOrder: 1000,
seo: {
seoTitle: "",
metaDescription: "",
focusKeyphrase: "",
tags: [],
ogTitle: "",
ogDescription: "",
ogImage: "",
},
});
const [inclusionsList, setInclusionsList] = useState([
{ id: Date.now(), category: "", items: "" },
]);
const [catForm, setCatForm] = useState({
name: "",
slug: "",
sortOrder: 1000,
isActive: true,
});
const fetchData = useCallback(async () => {
setLoading(true);
setError("");
try {
const [p, c] = await Promise.all([
getHealthPackagesApi(),
getHealthCategoriesApi(),
]);
setPackages(p.data || []);
setCategories(c.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(() => {
fetchData();
}, [fetchData]);
// --- Package Filtering & Pagination ---
const filteredPackages = useMemo(() => {
return packages.filter((pkg) => {
const matchesSearch =
pkg.name.toLowerCase().includes(searchText.toLowerCase()) ||
pkg.category?.name.toLowerCase().includes(searchText.toLowerCase());
const matchesCat = filterCategory
? pkg.categoryId === Number(filterCategory)
: true;
return matchesSearch && matchesCat;
});
}, [packages, searchText, filterCategory]);
useEffect(() => {
setCurrentPage(1);
}, [searchText, filterCategory]);
const totalPages = Math.ceil(filteredPackages.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredPackages.slice(
indexOfFirstItem,
indexOfLastItem,
);
// --- Actions ---
const handleToggleStatus = async (pkg: HealthPackage) => {
if (!pkg.id) return;
try {
await updateHealthPackageApi(pkg.id, { isActive: !pkg.isActive });
toast.success(`Package ${pkg.isActive ? "hidden" : "activated"}`);
fetchData();
} catch (err) {
console.error("Failed to update status", err);
toast.error("Failed to update status");
}
};
const handleToggleCategoryStatus = async (cat: HealthCategory) => {
if (!cat.id) return;
try {
if (cat.isActive) {
const proceed = window.confirm(
"Hiding this category will also hide all packages inside it. Proceed?",
);
if (!proceed) return;
}
await updateCategoryApi(cat.id, { isActive: !cat.isActive });
toast.success(`Category ${cat.isActive ? "hidden" : "activated"}`);
fetchData();
} catch (err) {
console.error("Failed to update category status", err);
toast.error("Failed to update category status");
}
};
const openAddPackage = () => {
if (categories.length === 0) {
toast.error(
"Please create at least one category before attempting to add a health package.",
);
return;
}
setEditingPackage(null);
setPkgForm({
name: "",
slug: "",
description: "",
image: "",
price: undefined,
discountedPrice: undefined,
categoryId: categories[0]?.id || 0,
isActive: true,
sortOrder: 1000,
seo: {
seoTitle: "",
metaDescription: "",
focusKeyphrase: "",
tags: [],
ogTitle: "",
ogDescription: "",
ogImage: "",
},
});
setInclusionsList([{ id: Date.now(), category: "", items: "" }]);
setPackageModal(true);
};
const openEditPackage = (pkg: any) => {
setEditingPackage(pkg);
setPkgForm(pkg);
if (
pkg.inclusions &&
typeof pkg.inclusions === "object" &&
!Array.isArray(pkg.inclusions)
) {
const formattedList = Object.entries(pkg.inclusions).map(
([cat, items], idx) => ({
id: Date.now() + idx,
category: cat,
items: (items as string[]).join(", "),
}),
);
setInclusionsList(
formattedList.length
? formattedList
: [{ id: Date.now(), category: "", items: "" }],
);
} else {
setInclusionsList([{ id: Date.now(), category: "", items: "" }]);
}
setPackageModal(true);
};
const savePackage = async () => {
if (!pkgForm.image) return toast.error("Package image is required.");
if (!pkgForm.name?.trim()) return toast.error("Package Name is required.");
if (!pkgForm.slug?.trim()) return toast.error("URL Slug is required.");
if (!pkgForm.categoryId)
return toast.error("Please select a valid category.");
if (!pkgForm.description?.trim())
return toast.error("Description is required.");
const structureFilled = inclusionsList.some(
(item) => item.category.trim() !== "" && item.items.trim() !== "",
);
if (!structureFilled) {
return toast.error(
"Please provide at least one valid Category Group with tests inside it.",
);
}
try {
const parsedInclusions: Record<string, string[]> = {};
inclusionsList.forEach((entry) => {
const catName = entry.category.trim();
if (catName) {
parsedInclusions[catName] = entry.items
.split(",")
.map((i) => i.trim())
.filter(Boolean);
}
});
const finalData: Partial<HealthPackage> = {
...pkgForm,
inclusions: parsedInclusions,
};
finalData.price =
finalData.price !== undefined && finalData.price !== null
? Number(finalData.price)
: null;
finalData.discountedPrice =
finalData.discountedPrice !== undefined &&
finalData.discountedPrice !== null
? Number(finalData.discountedPrice)
: null;
if (editingPackage?.id) {
const changedFields: Record<string, any> = {};
Object.keys(finalData).forEach((key) => {
const k = key as keyof HealthPackage;
if (
JSON.stringify(finalData[k]) !== JSON.stringify(editingPackage[k])
) {
changedFields[k] = finalData[k];
}
});
delete changedFields.id;
delete changedFields.category;
if (Object.keys(changedFields).length === 0) {
setPackageModal(false);
return;
}
await updateHealthPackageApi(editingPackage.id, changedFields);
} else {
await createHealthPackageApi(finalData);
}
setPackageModal(false);
fetchData();
} catch (err) {
console.error(err);
toast.error(
"An unexpected system error occurred while trying to save the package.",
);
}
};
const saveCategory = async () => {
if (!catForm.name?.trim()) return toast.error("Category Name is required.");
try {
if (editingCategory?.id) {
const changedFields: Record<string, any> = {};
Object.keys(catForm).forEach((key) => {
const k = key as keyof HealthCategory;
if (catForm[k] !== editingCategory[k]) {
changedFields[k] = catForm[k];
}
});
delete changedFields.id;
delete changedFields._count;
if (Object.keys(changedFields).length === 0) {
setCategoryModal(false);
return;
}
await updateCategoryApi(
editingCategory.id,
changedFields as Partial<HealthCategory>,
);
} else {
await createCategoryApi(catForm as any);
}
setCategoryModal(false);
fetchData();
} catch (err) {
console.error(err);
toast.error("An error occurred while saving the category.");
}
};
const previewUrl = `${WEBSITE_URL}/preventivecheckupdirectory/${selectedPackage?.slug}`;
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">Health Packages</h1>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search packages..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[250px] text-base"
/>
<select
value={filterCategory}
onChange={(e) => setFilterCategory(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 Categories</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
<Button
variant="outline"
onClick={fetchData}
disabled={loading}
className="text-base"
>
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={openAddPackage} className="text-base">
<Plus className="mr-2 h-5 w-5" />
Add Package
</Button>
</div>
</div>
{error && (
<div className="p-4 text-red-600 bg-red-50 border rounded-md text-base">
{error}
</div>
)}
<Tabs defaultValue="packages" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="packages">Packages</TabsTrigger>
<TabsTrigger value="categories">Categories</TabsTrigger>
<TabsTrigger value="inquiries">Inquiries</TabsTrigger>
</TabsList>
{/* PACKAGES TAB */}
<TabsContent value="packages">
<Card>
<CardHeader>
<CardTitle className="text-xl">Package 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-[80px] bg-background text-sm font-bold">
Priority
</TableHead>
<TableHead className="w-[250px] bg-background text-sm font-bold">
Package Details
</TableHead>
<TableHead className="w-[150px] bg-background text-sm font-bold">
Category
</TableHead>
<TableHead className="w-[150px] bg-background text-sm font-bold">
Pricing
</TableHead>
<TableHead className="w-[120px] bg-background text-sm font-bold">
Status
</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 packages found
</TableCell>
</TableRow>
) : (
currentItems.map((pkg) => (
<TableRow key={pkg.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-sm">
{pkg.sortOrder}
</TableCell>
<TableCell>
<div
className="font-semibold text-base truncate"
title={pkg.name}
>
{pkg.name}
</div>
<div className="text-xs text-muted-foreground truncate font-mono mt-0.5">
/{pkg.slug}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs">
{pkg.category?.name}
</Badge>
</TableCell>
<TableCell>
<div className="font-semibold">
{pkg.discountedPrice != null
? `${pkg.discountedPrice}`
: pkg.price != null
? `${pkg.price}`
: "Not Entered"}
</div>
{pkg.discountedPrice != null &&
pkg.price != null &&
pkg.discountedPrice < pkg.price && (
<div className="text-xs text-muted-foreground line-through">
{pkg.price}
</div>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch
checked={pkg.isActive}
onCheckedChange={() => handleToggleStatus(pkg)}
/>
<Badge
variant={pkg.isActive ? "default" : "secondary"}
>
{pkg.isActive ? "Active" : "Hidden"}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={() => {
setSelectedPackage(pkg);
setViewModal(true);
}}
>
<Eye className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={() => openEditPackage(pkg)}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && filteredPackages.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, filteredPackages.length)}
</span>{" "}
of{" "}
<span className="font-semibold">
{filteredPackages.length}
</span>{" "}
packages
</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>
</TabsContent>
{/* CATEGORIES TAB */}
<TabsContent value="categories">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-xl">Category List</CardTitle>
<Button
size="sm"
onClick={() => {
setEditingCategory(null);
setCatForm({
name: "",
slug: "",
sortOrder: 1000,
isActive: true,
});
setCategoryModal(true);
}}
>
<LayoutGrid className="mr-2 h-4 w-4" /> Add Category
</Button>
</CardHeader>
<CardContent className="p-0 sm:p-6">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full 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">
Priority
</TableHead>
<TableHead className="bg-background text-sm font-bold">
Category Name
</TableHead>
<TableHead className="w-[100px] bg-background text-sm font-bold">
Status
</TableHead>
<TableHead className="w-[100px] bg-background text-right text-sm font-bold">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map((cat) => (
<TableRow key={cat.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-sm">
{cat.sortOrder}
</TableCell>
<TableCell className="font-semibold text-base">
{cat.name}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch
checked={cat.isActive}
onCheckedChange={() =>
handleToggleCategoryStatus(cat)
}
/>
<Badge
variant={cat.isActive ? "default" : "secondary"}
>
{cat.isActive ? "Active" : "Hidden"}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="icon"
variant="ghost"
className="h-9 w-9"
onClick={() => {
setEditingCategory(cat);
setCatForm(cat as any);
setCategoryModal(true);
}}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="inquiries">
<PackageInquiriesTab />
</TabsContent>
</Tabs>
{/* --- REPLACED MODAL CONTAINER --- */}
<HealthPackageModal
open={packageModal}
onOpenChange={setPackageModal}
editingPackage={editingPackage}
pkgForm={pkgForm}
setPkgForm={setPkgForm}
inclusionsList={inclusionsList}
setInclusionsList={setInclusionsList}
categories={categories}
onSave={savePackage}
/>
{/* --- CATEGORY MODAL --- */}
<Dialog open={categoryModal} onOpenChange={setCategoryModal}>
<DialogContent className="w-full !max-w-2xl flex flex-col p-0 overflow-hidden">
<DialogHeader className="p-6 border-b">
<DialogTitle>
{editingCategory ? "Edit Category" : "Add Category"}
</DialogTitle>
</DialogHeader>
<div className="p-6 space-y-4">
<div className="space-y-1">
<Label className="text-sm font-semibold">Category Name</Label>
<Input
value={catForm.name}
onChange={(e) =>
setCatForm({ ...catForm, name: e.target.value })
}
className="text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">Sort Order</Label>
<Input
type="number"
value={catForm.sortOrder}
onChange={(e) =>
setCatForm({ ...catForm, sortOrder: Number(e.target.value) })
}
className="text-base"
/>
</div>
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/30 mb-2">
<Label className="text-base font-semibold cursor-pointer">
Active Visibility
</Label>
<Switch
checked={catForm.isActive}
onCheckedChange={(val) =>
setCatForm({ ...catForm, isActive: val })
}
/>
</div>
</div>
<DialogFooter className="p-6 border-t">
<Button variant="ghost" onClick={() => setCategoryModal(false)}>
Cancel
</Button>
<Button onClick={saveCategory}>
{editingCategory ? "Save Category" : "Create Category"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SeoPreview
open={viewModal}
onOpenChange={setViewModal}
previewData={selectedPackage}
url={previewUrl}
/>
</div>
);
}
+1
View File
@@ -233,6 +233,7 @@ export default function EmailPage() {
<option value="CANDIDATE">CANDIDATE</option>
<option value="ACADEMICS">ACADEMICS</option>
<option value="INQUIRY">INQUIRY</option>
<option value="HCINQUIRY">HC-APPOINTMENT</option>
</select>
<select