Compare commits

..

14 Commits

Author SHA1 Message Date
Kailasdevdas 99601f9f0d feat: facility and google review crud 2026-06-25 16:59:11 +05:30
kailasdevdas a88d2e3d8c Merge pull request 'feat: add featured doctors and health packages APIs' (#50) from feat/featured-doctors-packages-api into dev
Reviewed-on: #50
2026-06-24 11:16:19 +00:00
Kailasdevdas 5da63492ff feat: featured doctor toggle 2026-06-24 16:43:14 +05:30
Kailasdevdas d0860a3be4 feat: add featured doctors and health packages APIs 2026-06-24 10:40:35 +05:30
kailasdevdas 652320371f Merge pull request 'feat: accreditation crud' (#49) from feat/accreditation-crud into dev
Reviewed-on: #49
2026-06-23 06:10:29 +00:00
Kailasdevdas bb8cdc224b feat: accreditation crud 2026-06-23 11:39:09 +05:30
kailasdevdas c0d96806b1 Merge pull request 'feat: insurance crud' (#48) from feat/insurance-crud into dev
Reviewed-on: #48
2026-06-22 06:53:46 +00:00
Kailasdevdas c077574cbb feat: insurance crud 2026-06-17 14:36:07 +05:30
kailasdevdas 70526d1102 Merge pull request 'fix: prevent public access to inactive doctors' (#47) from fix/inactive-doctors into dev
Reviewed-on: #47
2026-06-16 07:29:22 +00:00
kailasdevdas 09b385429f Merge pull request 'feat: home page banner crud' (#46) from feat/homepage-banner into dev
Reviewed-on: #46
2026-06-16 07:24:43 +00:00
Kailasdevdas 545a78f32c feat: include video/image upload 2026-06-16 12:53:41 +05:30
Kailasdevdas 5444db8336 feat: home page banner crud 2026-06-15 09:30:19 +05:30
Kailasdevdas ccc5912ed9 fix: prevent public access to inactive doctors 2026-06-11 11:25:49 +05:30
kailasdevdas 131cd46f8d Merge pull request 'chore: setup prettier' (#45) from chore/code-formatting-setup into dev
Reviewed-on: #45
2026-05-26 10:32:16 +00:00
45 changed files with 5205 additions and 37 deletions
@@ -0,0 +1,22 @@
-- CreateEnum
CREATE TYPE "BannerMediaType" AS ENUM ('IMAGE', 'VIDEO');
-- CreateTable
CREATE TABLE "HomepageBanner" (
"id" SERIAL NOT NULL,
"title" TEXT,
"subtitle" TEXT,
"mediaType" "BannerMediaType" NOT NULL,
"desktopMediaUrl" TEXT NOT NULL,
"mobileMediaUrl" TEXT,
"buttonText" TEXT,
"buttonLink" TEXT,
"openInNewTab" BOOLEAN NOT NULL DEFAULT false,
"textAlignment" TEXT DEFAULT 'left',
"sortOrder" INTEGER NOT NULL DEFAULT 1000,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HomepageBanner_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "InsurancePartner" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"logo" TEXT NOT NULL,
"websiteUrl" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 1000,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InsurancePartner_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "AccreditationType" AS ENUM ('ACCREDITATION', 'CERTIFICATION');
-- CreateTable
CREATE TABLE "Accreditation" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"type" "AccreditationType" NOT NULL,
"logo" TEXT,
"image" TEXT,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 1000,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Accreditation_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "isFeatured" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,49 @@
-- CreateTable
CREATE TABLE "Facility" (
"id" SERIAL NOT NULL,
"facilityId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"shortDescription" TEXT,
"description" TEXT,
"videoUrl" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 1000,
"departmentId" INTEGER,
"seoId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Facility_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FacilityImage" (
"id" SERIAL NOT NULL,
"url" TEXT NOT NULL,
"altText" TEXT,
"description" TEXT,
"facilityId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FacilityImage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Facility_facilityId_key" ON "Facility"("facilityId");
-- CreateIndex
CREATE UNIQUE INDEX "Facility_slug_key" ON "Facility"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Facility_seoId_key" ON "Facility"("seoId");
-- AddForeignKey
ALTER TABLE "Facility" ADD CONSTRAINT "Facility_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Facility" ADD CONSTRAINT "Facility_seoId_fkey" FOREIGN KEY ("seoId") REFERENCES "Seo"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FacilityImage" ADD CONSTRAINT "FacilityImage_facilityId_fkey" FOREIGN KEY ("facilityId") REFERENCES "Facility"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "GoogleReview" (
"id" SERIAL NOT NULL,
"reviewerName" TEXT NOT NULL,
"reviewerImage" TEXT,
"rating" INTEGER NOT NULL,
"review" TEXT NOT NULL,
"reviewDate" TIMESTAMP(3),
"googleReviewUrl" TEXT,
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"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 "GoogleReview_pkey" PRIMARY KEY ("id")
);
+124 -1
View File
@@ -27,6 +27,7 @@ model Doctor {
workingStatus String?
qualification String?
isActive Boolean @default(true)
isFeatured Boolean @default(false)
globalSortOrder Int @default(1000)
specializations DoctorSpecialization[]
professionalSummary String? @db.Text
@@ -45,12 +46,12 @@ model Department {
name String
image String?
para1 String?
para2 String?
para3 String?
facilities String?
services String?
facilitiesList Facility[]
isActive Boolean @default(true)
sortOrder Int @default(1000)
@@ -299,6 +300,7 @@ model Seo {
id Int @id @default(autoincrement())
doctor Doctor?
healthPackage HealthPackage?
facility Facility?
seoTitle String?
@@ -313,4 +315,125 @@ model Seo {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model HomepageBanner {
id Int @id @default(autoincrement())
title String?
subtitle String?
mediaType BannerMediaType
desktopMediaUrl String
mobileMediaUrl String?
buttonText String?
buttonLink String?
openInNewTab Boolean @default(false)
textAlignment String? @default("left")
sortOrder Int @default(1000)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum BannerMediaType {
IMAGE
VIDEO
}
model InsurancePartner {
id Int @id @default(autoincrement())
name String
logo String
websiteUrl String?
sortOrder Int @default(1000)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Accreditation {
id Int @id @default(autoincrement())
title String
type AccreditationType
logo String?
image String?
description String? @db.Text
sortOrder Int @default(1000)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum AccreditationType {
ACCREDITATION
CERTIFICATION
}
model Facility {
id Int @id @default(autoincrement())
facilityId String @unique
name String
slug String @unique
shortDescription String? @db.Text
description String? @db.Text
videoUrl String?
isActive Boolean @default(true)
isFeatured Boolean @default(false)
sortOrder Int @default(1000)
images FacilityImage[]
departmentId Int?
department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull)
seoId Int? @unique
seo Seo? @relation(fields: [seoId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model FacilityImage {
id Int @id @default(autoincrement())
url String
altText String?
description String?
facilityId Int
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
}
model GoogleReview {
id Int @id @default(autoincrement())
reviewerName String
reviewerImage String?
rating Int
review String @db.Text
reviewDate DateTime?
googleReviewUrl String?
isFeatured Boolean @default(false)
isActive Boolean @default(true)
sortOrder Int @default(1000)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+10
View File
@@ -16,6 +16,11 @@ 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';
import homepageBannerRoutes from './routes/homepageBanner.routes.js';
import insurancePartnerRoutes from './routes/insurancePartner.routes.js';
import accreditationRoutes from './routes/accreditation.routes.js';
import facilityRoutes from './routes/facility.routes.js';
import gReviewRoutes from './routes/googleReview.routes.js';
dotenv.config();
@@ -57,6 +62,11 @@ app.use('/api/email', emailConfigRoutes);
app.use('/api/newsMedia', newsMediaRoutes);
app.use('/api/import', importRoutes);
app.use('/api/health-check', healthCheckRoutes);
app.use('/api/homepage-banners', homepageBannerRoutes);
app.use('/api/insurance-partners', insurancePartnerRoutes);
app.use('/api/accreditation', accreditationRoutes);
app.use('/api/facilities', facilityRoutes);
app.use('/api/google-reviews', gReviewRoutes);
const PORT = process.env.PORT || 5008;
app.listen(PORT, () => {
@@ -0,0 +1,189 @@
import prisma from '../prisma/client.js';
export const createAccreditation = async (req, res) => {
try {
const { title, type, logo, image, description, sortOrder, isActive } = req.body;
if (!title || !type) {
return res.status(400).json({
success: false,
message: 'Title and type are required',
});
}
const accreditation = await prisma.accreditation.create({
data: {
title,
type,
logo,
image,
description,
sortOrder: sortOrder ? Number(sortOrder) : 1000,
isActive: isActive ?? true,
},
});
res.status(201).json({
success: true,
data: accreditation,
message: 'Accreditation created successfully',
});
} catch (error) {
console.error('PRISMA TRANSACTION ERROR:', error);
res.status(500).json({
success: false,
message: 'Failed to create accreditation',
});
}
};
export const getAccreditations = async (req, res) => {
try {
const { type } = req.query;
const accreditations = await prisma.accreditation.findMany({
where: type
? {
type,
}
: undefined,
orderBy: {
sortOrder: 'asc',
},
});
res.json({
success: true,
data: accreditations,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch accreditations',
});
}
};
export const getActiveAccreditations = async (req, res) => {
try {
const { type } = req.query;
const accreditations = await prisma.accreditation.findMany({
where: {
isActive: true,
...(type && { type }),
},
orderBy: {
sortOrder: 'asc',
},
});
res.json({
success: true,
data: accreditations,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch accreditations',
});
}
};
export const getAccreditation = async (req, res) => {
try {
const { id } = req.params;
const accreditation = await prisma.accreditation.findUnique({
where: {
id: Number(id),
},
});
if (!accreditation) {
return res.status(404).json({
success: false,
message: 'Accreditation not found',
});
}
res.json({
success: true,
data: accreditation,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch accreditation',
});
}
};
export const updateAccreditation = async (req, res) => {
try {
const { id } = req.params;
const { title, type, logo, image, description, sortOrder, isActive } = req.body;
const updateData = {};
if (title !== undefined) updateData.title = title;
if (type !== undefined) updateData.type = type;
if (logo !== undefined) updateData.logo = logo;
if (image !== undefined) updateData.image = image;
if (description !== undefined) updateData.description = description;
if (sortOrder !== undefined) updateData.sortOrder = Number(sortOrder);
if (isActive !== undefined) updateData.isActive = isActive;
const accreditation = await prisma.accreditation.update({
where: {
id: Number(id),
},
data: updateData,
});
res.json({
success: true,
data: accreditation,
message: 'Accreditation updated successfully',
});
} catch (error) {
console.error('PRISMA TRANSACTION ERROR:', error);
res.status(500).json({
success: false,
message: 'Failed to update accreditation',
});
}
};
export const deleteAccreditation = async (req, res) => {
try {
const { id } = req.params;
await prisma.accreditation.delete({
where: {
id: Number(id),
},
});
res.json({
success: true,
message: 'Accreditation deleted successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to delete accreditation',
});
}
};
+82 -5
View File
@@ -34,6 +34,7 @@ export const getAllDoctors = async (req, res) => {
workingStatus: doc.workingStatus,
qualification: doc.qualification,
isActive: doc.isActive,
isFeatured: doc.isFeatured,
experience: doc.experience,
professionalSummary: doc.professionalSummary,
globalSortOrder: doc.globalSortOrder,
@@ -93,9 +94,13 @@ export const getAllDoctors = async (req, res) => {
export const getDoctorByDoctorId = async (req, res) => {
try {
const { doctorId } = req.params;
const { admin } = req.query;
const doctor = await prisma.doctor.findUnique({
where: { doctorId },
const doctor = await prisma.doctor.findFirst({
where: {
doctorId,
...(admin === 'true' ? {} : { isActive: true }),
},
include: {
seo: true,
specializations: true,
@@ -124,6 +129,8 @@ export const getDoctorByDoctorId = async (req, res) => {
qualification: doctor.qualification,
experience: doctor.experience,
professionalSummary: doctor.professionalSummary,
isActive: doctor.isActive,
isFeatured: doctor.isFeatured,
seo: {
seoTitle: doctor.seo?.seoTitle ?? '',
metaDescription: doctor.seo?.metaDescription ?? '',
@@ -235,6 +242,7 @@ export const createDoctor = async (req, res) => {
workingStatus,
qualification,
isActive,
isFeatured,
globalSortOrder,
departments,
experience,
@@ -292,6 +300,7 @@ export const createDoctor = async (req, res) => {
professionalSummary,
seoId: seo.id,
isActive: isActive !== undefined ? isActive : true,
isFeatured: isFeatured !== undefined ? isFeatured : false,
globalSortOrder: globalSortOrder !== undefined ? Number(globalSortOrder) : 0,
},
});
@@ -356,6 +365,7 @@ export const updateDoctor = async (req, res) => {
workingStatus,
qualification,
isActive,
isFeatured,
globalSortOrder,
departments,
experience,
@@ -392,6 +402,19 @@ export const updateDoctor = async (req, res) => {
message: `Doctor has been ${doctor.isActive ? 'deactivated' : 'activated'} successfully`,
});
}
if (action === 'toggleFeatured') {
await prisma.doctor.update({
where: { id: doctor.id },
data: {
isFeatured: !doctor.isFeatured,
},
});
return res.status(200).json({
success: true,
message: `Doctor has been ${doctor.isFeatured ? 'removed from featured' : 'marked as featured'} successfully`,
});
}
const messages = [];
if (!doctorId) messages.push('Doctor ID is required');
@@ -418,7 +441,8 @@ export const updateDoctor = async (req, res) => {
image,
workingStatus,
qualification,
isActive,
isActive: isActive !== undefined ? isActive : undefined,
isFeatured: isFeatured !== undefined ? isFeatured : undefined,
experience: experience ? Number(experience) : null,
professionalSummary,
globalSortOrder: globalSortOrder !== undefined ? Number(globalSortOrder) : undefined,
@@ -648,9 +672,13 @@ export const getDoctorTimings = async (req, res) => {
export const getDoctorTimingById = async (req, res) => {
try {
const { doctorId } = req.params;
const { admin } = req.query;
const doctor = await prisma.doctor.findUnique({
where: { doctorId },
const doctor = await prisma.doctor.findFirst({
where: {
doctorId,
...(admin === 'true' ? {} : { isActive: true }),
},
include: {
departments: {
include: {
@@ -691,3 +719,52 @@ export const getDoctorTimingById = async (req, res) => {
});
}
};
export const getFeaturedDoctors = async (req, res) => {
try {
const doctors = await prisma.doctor.findMany({
where: {
isActive: true,
isFeatured: true,
},
include: {
seo: {
select: {
slug: true,
},
},
departments: {
include: {
department: true,
},
},
},
orderBy: [{ globalSortOrder: 'asc' }, { name: 'asc' }],
});
const data = doctors.map((doc) => ({
doctorId: doc.doctorId,
name: doc.name,
image: doc.image ?? '',
designation: doc.designation,
qualification: doc.qualification,
experience: doc.experience,
slug: doc.seo?.slug ?? '',
departments: doc.departments.map((d) => ({
departmentId: d.department.departmentId,
departmentName: d.department.name,
})),
}));
res.status(200).json({
success: true,
data,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch featured doctors',
});
}
};
@@ -0,0 +1,421 @@
import prisma from '../prisma/client.js';
export const getAllFacilities = async (req, res) => {
try {
const { admin } = req.query;
const facilities = await prisma.facility.findMany({
where: admin === 'true' ? {} : { isActive: true },
include: {
seo: true,
department: true,
images: true,
},
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
});
const formatted = facilities.map((fac, index) => ({
SL_NO: String(index + 1),
id: fac.id,
facilityId: fac.facilityId,
name: fac.name,
slug: fac.slug,
shortDescription: fac.shortDescription,
description: fac.description,
videoUrl: fac.videoUrl ?? '',
isActive: fac.isActive,
isFeatured: fac.isFeatured,
sortOrder: fac.sortOrder,
department: fac.department
? {
id: fac.department.id,
departmentId: fac.department.departmentId,
name: fac.department.name,
}
: null,
images: fac.images.map((img) => ({
id: img.id,
url: img.url,
altText: img.altText ?? '',
description: img.description ?? '',
})),
seo: {
seoTitle: fac.seo?.seoTitle ?? '',
metaDescription: fac.seo?.metaDescription ?? '',
focusKeyphrase: fac.seo?.focusKeyphrase ?? '',
slug: fac.seo?.slug ?? '',
tags: fac.seo?.tags ?? [],
ogTitle: fac.seo?.ogTitle ?? '',
ogDescription: fac.seo?.ogDescription ?? '',
ogImage: fac.seo?.ogImage ?? '',
},
}));
res.status(200).json({
success: true,
data: formatted,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch facilities',
});
}
};
export const getFacilityByFacilityId = async (req, res) => {
try {
const { facilityId } = req.params;
const { admin } = req.query;
const facility = await prisma.facility.findFirst({
where: {
facilityId,
...(admin === 'true' ? {} : { isActive: true }),
},
include: {
seo: true,
department: true,
images: true,
},
});
if (!facility) {
return res.status(404).json({
success: false,
message: 'Facility not found',
});
}
const response = {
facilityId: facility.facilityId,
name: facility.name,
slug: facility.slug,
shortDescription: facility.shortDescription,
description: facility.description,
videoUrl: facility.videoUrl ?? '',
isActive: facility.isActive,
isFeatured: facility.isFeatured,
sortOrder: facility.sortOrder,
department: facility.department
? {
departmentId: facility.department.departmentId,
name: facility.department.name,
}
: null,
images: facility.images.map((img) => ({
id: img.id,
url: img.url,
altText: img.altText ?? '',
description: img.description ?? '',
})),
seo: {
seoTitle: facility.seo?.seoTitle ?? '',
metaDescription: facility.seo?.metaDescription ?? '',
focusKeyphrase: facility.seo?.focusKeyphrase ?? '',
slug: facility.seo?.slug ?? '',
tags: facility.seo?.tags ?? [],
ogTitle: facility.seo?.ogTitle ?? '',
ogDescription: facility.seo?.ogDescription ?? '',
ogImage: facility.seo?.ogImage ?? '',
},
};
res.status(200).json({
success: true,
data: response,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch facility',
});
}
};
export const createFacility = async (req, res) => {
try {
const {
facilityId,
name,
slug,
shortDescription,
description,
videoUrl,
isActive,
isFeatured,
sortOrder,
departmentId,
images,
seoTitle,
metaDescription,
focusKeyphrase,
tags,
ogTitle,
ogDescription,
ogImage,
} = req.body;
const messages = [];
if (!facilityId) messages.push('Facility ID is required');
if (!name?.trim()) messages.push('Facility name is required');
if (!slug?.trim()) messages.push('Slug is required');
if (messages.length > 0) {
return res.status(400).json({
success: false,
message: messages.join(', '),
});
}
let dbDepartmentId = null;
if (departmentId) {
const targetDept = await prisma.department.findUnique({
where: { departmentId: departmentId },
});
if (targetDept) {
dbDepartmentId = targetDept.id;
}
}
const seo = await prisma.seo.create({
data: {
seoTitle,
metaDescription,
focusKeyphrase,
slug: slug ? slug : null,
tags: tags || [],
ogTitle,
ogDescription,
ogImage,
},
});
const facility = await prisma.facility.create({
data: {
facilityId,
name,
slug,
shortDescription,
description,
videoUrl,
isActive: isActive !== undefined ? isActive : true,
isFeatured: isFeatured !== undefined ? isFeatured : false,
sortOrder: sortOrder !== undefined ? Number(sortOrder) : 1000,
departmentId: dbDepartmentId,
seoId: seo.id,
},
});
if (images && images.length > 0) {
await prisma.facilityImage.createMany({
data: images.map((img) => ({
url: img.url,
altText: img.altText || null,
description: img.description || null,
facilityId: facility.id,
})),
});
}
res.status(201).json({
success: true,
message: 'Facility created successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to create facility',
});
}
};
export const updateFacility = async (req, res) => {
try {
const { facilityId, action } = req.params;
const {
name,
slug,
shortDescription,
description,
videoUrl,
isActive,
isFeatured,
sortOrder,
departmentId,
images,
seoTitle,
metaDescription,
focusKeyphrase,
tags,
ogTitle,
ogDescription,
ogImage,
} = req.body;
if (!facilityId) {
return res.status(400).json({ success: false, message: 'Facility ID is required' });
}
const facility = await prisma.facility.findUnique({ where: { facilityId } });
if (!facility) return res.status(404).json({ success: false, message: 'Facility not found' });
if (action === 'toggleStatus') {
await prisma.facility.update({
where: { id: facility.id },
data: { isActive: !facility.isActive },
});
return res.status(200).json({
success: true,
message: `Facility has been ${facility.isActive ? 'deactivated' : 'activated'} successfully`,
});
}
if (action === 'toggleFeatured') {
await prisma.facility.update({
where: { id: facility.id },
data: { isFeatured: !facility.isFeatured },
});
return res.status(200).json({
success: true,
message: `Facility has been ${facility.isFeatured ? 'removed from featured' : 'marked as featured'} successfully`,
});
}
const messages = [];
if (!name?.trim()) messages.push('Facility name is required');
if (!slug?.trim()) messages.push('Slug is required');
if (messages.length > 0) {
return res.status(400).json({ success: false, message: messages.join(', ') });
}
let dbDepartmentId = undefined;
if (departmentId !== undefined) {
if (departmentId) {
const targetDept = await prisma.department.findUnique({
where: { departmentId: departmentId },
});
dbDepartmentId = targetDept ? targetDept.id : null;
} else {
dbDepartmentId = null;
}
}
await prisma.facility.update({
where: { id: facility.id },
data: {
name,
slug,
shortDescription,
description,
videoUrl,
isActive: isActive !== undefined ? isActive : undefined,
isFeatured: isFeatured !== undefined ? isFeatured : undefined,
sortOrder: sortOrder !== undefined ? Number(sortOrder) : undefined,
departmentId: dbDepartmentId,
},
});
if (facility.seoId) {
await prisma.seo.update({
where: { id: facility.seoId },
data: {
seoTitle,
metaDescription,
focusKeyphrase,
slug: slug ? slug : null,
tags: tags || [],
ogTitle,
ogDescription,
ogImage,
},
});
} else {
const seo = await prisma.seo.create({
data: {
seoTitle,
metaDescription,
focusKeyphrase,
slug: slug ? slug : null,
tags: tags || [],
ogTitle,
ogDescription,
ogImage,
},
});
await prisma.facility.update({ where: { id: facility.id }, data: { seoId: seo.id } });
}
if (Array.isArray(images)) {
await prisma.facilityImage.deleteMany({ where: { facilityId: facility.id } });
if (images.length > 0) {
await prisma.facilityImage.createMany({
data: images.map((img) => ({
url: img.url,
altText: img.altText || null,
description: img.description || null,
facilityId: facility.id,
})),
});
}
}
res.status(200).json({ success: true, message: 'Facility updated successfully' });
} catch (error) {
console.error('Update Error:', error);
res.status(500).json({ success: false, message: 'Failed to update facility' });
}
};
export const deleteFacility = async (req, res) => {
try {
const { facilityId } = req.params;
const facility = await prisma.facility.findUnique({ where: { facilityId } });
if (!facility) {
return res.status(404).json({ success: false, message: 'Facility not found' });
}
await prisma.facilityImage.deleteMany({ where: { facilityId: facility.id } });
await prisma.facility.delete({ where: { id: facility.id } });
res.status(200).json({
success: true,
message: 'Facility deleted successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({ success: false, message: 'Failed to delete facility' });
}
};
export const getFeaturedFacilities = async (req, res) => {
try {
const facilities = await prisma.facility.findMany({
where: { isActive: true, isFeatured: true },
include: {
images: true,
department: true,
},
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
});
const data = facilities.map((fac) => ({
facilityId: fac.facilityId,
name: fac.name,
slug: fac.slug,
shortDescription: fac.shortDescription,
image: fac.images[0]?.url ?? '',
departmentName: fac.department?.name ?? '',
}));
res.status(200).json({ success: true, data });
} catch (error) {
console.error(error);
res.status(500).json({ success: false, message: 'Failed to fetch featured facilities' });
}
};
@@ -0,0 +1,224 @@
import prisma from '../prisma/client.js';
export const getFeaturedGoogleReviews = async (req, res) => {
try {
const reviews = await prisma.googleReview.findMany({
where: {
isActive: true,
isFeatured: true,
},
orderBy: {
sortOrder: 'asc',
},
});
res.json({
success: true,
data: reviews,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch featured Google reviews',
});
}
};
export const createGoogleReview = async (req, res) => {
try {
const {
reviewerName,
reviewerImage,
rating,
review,
reviewDate,
googleReviewUrl,
isFeatured,
isActive,
sortOrder,
} = req.body;
if (!reviewerName || !rating || !review) {
return res.status(400).json({
success: false,
message: 'Reviewer name, rating and review are required.',
});
}
const googleReview = await prisma.googleReview.create({
data: {
reviewerName,
reviewerImage,
rating: Number(rating),
review,
reviewDate: reviewDate ? new Date(reviewDate) : null,
googleReviewUrl,
isFeatured,
isActive,
sortOrder,
},
});
res.status(201).json({
success: true,
data: googleReview,
message: 'Google review created successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to create Google review',
});
}
};
export const getGoogleReviews = async (req, res) => {
try {
const reviews = await prisma.googleReview.findMany({
orderBy: {
sortOrder: 'asc',
},
});
res.json({
success: true,
data: reviews,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch Google reviews',
});
}
};
export const getActiveGoogleReviews = async (req, res) => {
try {
const reviews = await prisma.googleReview.findMany({
where: {
isActive: true,
},
orderBy: {
sortOrder: 'asc',
},
});
res.json({
success: true,
data: reviews,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch active Google reviews',
});
}
};
export const getGoogleReview = async (req, res) => {
try {
const { id } = req.params;
const review = await prisma.googleReview.findUnique({
where: {
id: Number(id),
},
});
if (!review) {
return res.status(404).json({
success: false,
message: 'Google review not found',
});
}
res.json({
success: true,
data: review,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch Google review',
});
}
};
export const updateGoogleReview = async (req, res) => {
try {
const { id } = req.params;
const {
reviewerName,
reviewerImage,
rating,
review,
reviewDate,
googleReviewUrl,
isFeatured,
isActive,
sortOrder,
} = req.body;
const dataToUpdate = {};
if (reviewerName !== undefined) dataToUpdate.reviewerName = reviewerName;
if (reviewerImage !== undefined) dataToUpdate.reviewerImage = reviewerImage;
if (rating !== undefined) dataToUpdate.rating = Number(rating);
if (review !== undefined) dataToUpdate.review = review;
if (reviewDate !== undefined) {
dataToUpdate.reviewDate = reviewDate ? new Date(reviewDate) : null;
}
if (googleReviewUrl !== undefined) dataToUpdate.googleReviewUrl = googleReviewUrl;
if (isFeatured !== undefined) dataToUpdate.isFeatured = isFeatured;
if (isActive !== undefined) dataToUpdate.isActive = isActive;
if (sortOrder !== undefined) dataToUpdate.sortOrder = sortOrder;
const googleReview = await prisma.googleReview.update({
where: {
id: Number(id),
},
data: dataToUpdate,
});
res.json({
success: true,
data: googleReview,
message: 'Google review updated successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to update Google review',
});
}
};
export const deleteGoogleReview = async (req, res) => {
try {
const { id } = req.params;
await prisma.googleReview.delete({
where: {
id: Number(id),
},
});
res.json({
success: true,
message: 'Google review deleted successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to delete Google review',
});
}
};
@@ -486,3 +486,33 @@ export const getAllInquiries = async (req, res) => {
return res.status(500).json({ success: false, message: 'Failed to fetch inquiries' });
}
};
export const getFeaturedPackages = async (req, res) => {
try {
const packages = await prisma.healthPackage.findMany({
where: {
isActive: true,
isFeatured: true,
category: {
isActive: true,
},
},
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 featured packages',
});
}
};
@@ -0,0 +1,203 @@
import prisma from '../prisma/client.js';
export const createHomepageBanner = async (req, res) => {
try {
const {
title,
subtitle,
mediaType,
desktopMediaUrl,
mobileMediaUrl,
buttonText,
buttonLink,
openInNewTab,
textAlignment,
sortOrder,
isActive,
} = req.body;
if (!mediaType || !desktopMediaUrl) {
return res.status(400).json({
success: false,
message: 'Media type and desktop media URL are required',
});
}
const banner = await prisma.homepageBanner.create({
data: {
title,
subtitle,
mediaType,
desktopMediaUrl,
mobileMediaUrl,
buttonText,
buttonLink,
openInNewTab,
textAlignment,
sortOrder,
isActive,
},
});
res.status(201).json({
success: true,
data: banner,
message: 'Homepage banner created successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to create homepage banner',
});
}
};
export const getHomepageBanners = async (req, res) => {
try {
const banners = await prisma.homepageBanner.findMany({
orderBy: {
sortOrder: 'asc',
},
});
res.json({
success: true,
data: banners,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch homepage banners',
});
}
};
export const getActiveHomepageBanners = async (req, res) => {
try {
const banners = await prisma.homepageBanner.findMany({
where: {
isActive: true,
},
orderBy: {
sortOrder: 'asc',
},
});
res.json({
success: true,
data: banners,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch active homepage banners',
});
}
};
export const getHomepageBanner = async (req, res) => {
try {
const { id } = req.params;
const banner = await prisma.homepageBanner.findUnique({
where: {
id: Number(id),
},
});
if (!banner) {
return res.status(404).json({
success: false,
message: 'Homepage banner not found',
});
}
res.json({
success: true,
data: banner,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch homepage banner',
});
}
};
export const updateHomepageBanner = async (req, res) => {
try {
const { id } = req.params;
const {
title,
subtitle,
mediaType,
desktopMediaUrl,
mobileMediaUrl,
buttonText,
buttonLink,
openInNewTab,
textAlignment,
sortOrder,
isActive,
} = req.body;
const banner = await prisma.homepageBanner.update({
where: {
id: Number(id),
},
data: {
title,
subtitle,
mediaType,
desktopMediaUrl,
mobileMediaUrl,
buttonText,
buttonLink,
openInNewTab,
textAlignment,
sortOrder,
isActive,
},
});
res.json({
success: true,
data: banner,
message: 'Homepage banner updated successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to update homepage banner',
});
}
};
export const deleteHomepageBanner = async (req, res) => {
try {
const { id } = req.params;
await prisma.homepageBanner.delete({
where: {
id: Number(id),
},
});
res.json({
success: true,
message: 'Homepage banner deleted successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to delete homepage banner',
});
}
};
@@ -0,0 +1,173 @@
import prisma from '../prisma/client.js';
export const createInsurancePartner = async (req, res) => {
try {
const { name, logo, websiteUrl, sortOrder, isActive } = req.body;
if (!name || !logo) {
return res.status(400).json({
success: false,
message: 'Name and logo are required',
});
}
const partner = await prisma.insurancePartner.create({
data: {
name,
logo,
websiteUrl,
sortOrder,
isActive,
},
});
res.status(201).json({
success: true,
data: partner,
message: 'Insurance partner created successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to create insurance partner',
});
}
};
export const getInsurancePartners = async (req, res) => {
try {
const partners = await prisma.insurancePartner.findMany({
orderBy: {
sortOrder: 'asc',
},
});
res.json({
success: true,
data: partners,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch insurance partners',
});
}
};
export const getActiveInsurancePartners = async (req, res) => {
try {
const partners = await prisma.insurancePartner.findMany({
where: {
isActive: true,
},
orderBy: {
sortOrder: 'asc',
},
});
res.json({
success: true,
data: partners,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch insurance partners',
});
}
};
export const getInsurancePartner = async (req, res) => {
try {
const { id } = req.params;
const partner = await prisma.insurancePartner.findUnique({
where: {
id: Number(id),
},
});
if (!partner) {
return res.status(404).json({
success: false,
message: 'Insurance partner not found',
});
}
res.json({
success: true,
data: partner,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch insurance partner',
});
}
};
export const updateInsurancePartner = async (req, res) => {
try {
const { id } = req.params;
const { name, logo, websiteUrl, sortOrder, isActive } = req.body;
const partner = await prisma.insurancePartner.update({
where: {
id: Number(id),
},
data: {
name,
logo,
websiteUrl,
sortOrder,
isActive,
},
});
res.json({
success: true,
data: partner,
message: 'Insurance partner updated successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to update insurance partner',
});
}
};
export const deleteInsurancePartner = async (req, res) => {
try {
const { id } = req.params;
await prisma.insurancePartner.delete({
where: {
id: Number(id),
},
});
res.json({
success: true,
message: 'Insurance partner deleted successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to delete insurance partner',
});
}
};
@@ -0,0 +1,24 @@
import express from 'express';
import {
createAccreditation,
getAccreditations,
getActiveAccreditations,
getAccreditation,
updateAccreditation,
deleteAccreditation,
} from '../controllers/accreditation.controller.js';
import jwtAuthMiddleware from '../middleware/auth.js';
const router = express.Router();
router.get('/active', getActiveAccreditations);
router.post('/', jwtAuthMiddleware, createAccreditation);
router.get('/getAll', jwtAuthMiddleware, getAccreditations);
router.get('/:id', jwtAuthMiddleware, getAccreditation);
router.put('/:id', jwtAuthMiddleware, updateAccreditation);
router.delete('/:id', jwtAuthMiddleware, deleteAccreditation);
export default router;
+2
View File
@@ -8,6 +8,7 @@ import {
getDoctorTimingById,
getDoctorByDoctorId,
getDoctorsByDepartmentId,
getFeaturedDoctors,
} from '../controllers/doctor.controller.js';
import jwtAuthMiddleware from '../middleware/auth.js';
@@ -18,6 +19,7 @@ router.get('/getAll', getAllDoctors);
router.get('/search', getDoctorsByDepartmentId);
router.get('/getTimings', getDoctorTimings);
router.get('/getTimings/:doctorId', getDoctorTimingById);
router.get('/featured', getFeaturedDoctors);
router.get('/:doctorId', getDoctorByDoctorId);
router.post('/', jwtAuthMiddleware, createDoctor);
+23
View File
@@ -0,0 +1,23 @@
import express from 'express';
import {
getAllFacilities,
getFacilityByFacilityId,
createFacility,
updateFacility,
deleteFacility,
getFeaturedFacilities,
} from '../controllers/facility.controller.js';
import jwtAuthMiddleware from '../middleware/auth.js';
const router = express.Router();
router.get('/getAll', getAllFacilities);
router.get('/featured', getFeaturedFacilities);
router.get('/:facilityId', getFacilityByFacilityId);
router.post('/', jwtAuthMiddleware, createFacility);
router.patch('/:facilityId/:action', jwtAuthMiddleware, updateFacility);
router.delete('/:facilityId', jwtAuthMiddleware, deleteFacility);
export default router;
+25
View File
@@ -0,0 +1,25 @@
import express from 'express';
import jwtAuthMiddleware from '../middleware/auth.js';
import {
createGoogleReview,
getGoogleReviews,
getActiveGoogleReviews,
getFeaturedGoogleReviews,
getGoogleReview,
updateGoogleReview,
deleteGoogleReview,
} from '../controllers/googleReview.controller.js';
const router = express.Router();
router.get('/active', getActiveGoogleReviews);
router.get('/featured', getFeaturedGoogleReviews);
router.post('/', jwtAuthMiddleware, createGoogleReview);
router.get('/getAll', jwtAuthMiddleware, getGoogleReviews);
router.get('/:id', jwtAuthMiddleware, getGoogleReview);
router.put('/:id', jwtAuthMiddleware, updateGoogleReview);
router.delete('/:id', jwtAuthMiddleware, deleteGoogleReview);
export default router;
+2
View File
@@ -12,6 +12,7 @@ import {
createPackage,
updatePackage,
deletePackage,
getFeaturedPackages,
// Inquiries
createPackageInquiry,
@@ -26,6 +27,7 @@ router.get('/packages', getAllPackages);
router.get('/packages/:slug', getPackageBySlug);
router.get('/categories', getAllCategories);
router.post('/inquiry', createPackageInquiry);
router.get('/featured', getFeaturedPackages);
router.get('/inquiries', jwtAuthMiddleware, getAllInquiries);
router.post('/', jwtAuthMiddleware, createPackage);
@@ -0,0 +1,27 @@
import express from 'express';
import {
createHomepageBanner,
getHomepageBanners,
getActiveHomepageBanners,
getHomepageBanner,
updateHomepageBanner,
deleteHomepageBanner,
} from '../controllers/homepageBanner.controller.js';
import jwtAuthMiddleware from '../middleware/auth.js';
const router = express.Router();
router.get('/active', getActiveHomepageBanners);
router.post('/', jwtAuthMiddleware, createHomepageBanner);
router.get('/getAll', jwtAuthMiddleware, getHomepageBanners);
router.get('/:id', jwtAuthMiddleware, getHomepageBanner);
router.put('/:id', jwtAuthMiddleware, updateHomepageBanner);
router.delete('/:id', jwtAuthMiddleware, deleteHomepageBanner);
export default router;
@@ -0,0 +1,28 @@
import express from 'express';
import {
createInsurancePartner,
getInsurancePartners,
getActiveInsurancePartners,
getInsurancePartner,
updateInsurancePartner,
deleteInsurancePartner,
} from '../controllers/insurancePartner.controller.js';
import jwtAuthMiddleware from '../middleware/auth.js';
const router = express.Router();
router.get('/active', getActiveInsurancePartners);
router.post('/', jwtAuthMiddleware, createInsurancePartner);
router.get('/getAll', jwtAuthMiddleware, getInsurancePartners);
router.get('/:id', jwtAuthMiddleware, getInsurancePartner);
router.put('/:id', jwtAuthMiddleware, updateInsurancePartner);
router.delete('/:id', jwtAuthMiddleware, deleteInsurancePartner);
export default router;
+10
View File
@@ -24,6 +24,11 @@ import NewsPage from './pages/newsMedia';
import BlogDetail from './pages/BlogDetails';
import ImportData from './pages/ImportData';
import HealthPackagePage from './pages/HealthPackagePage';
import HomepageBanner from './pages/HomepageBannerPage';
import InsurancePartnerPage from './pages/InsurancePartner';
import AccreditationPage from './pages/Accreditation';
import FacilityPage from './pages/FacilityPage';
import GoogleReviewPage from './pages/GoogleReviewPage';
export default function App() {
return (
@@ -53,6 +58,11 @@ export default function App() {
<Route path="/news" element={<NewsPage />} />
<Route path="/import" element={<ImportData />} />
<Route path="/health-check" element={<HealthPackagePage />} />
<Route path="/homepage-banner" element={<HomepageBanner />} />
<Route path="/insurance-partner" element={<InsurancePartnerPage />} />
<Route path="/accreditation" element={<AccreditationPage />} />
<Route path="/facility" element={<FacilityPage />} />
<Route path="/reviews" element={<GoogleReviewPage />} />
</Route>
</Route>
+85
View File
@@ -0,0 +1,85 @@
import apiClient from '@/api/client';
import toast from 'react-hot-toast';
export type AccreditationType = 'ACCREDITATION' | 'CERTIFICATION';
export interface Accreditation {
id?: number;
title: string;
type: AccreditationType;
logo?: string;
image?: string;
description?: string;
sortOrder: number;
isActive: boolean;
createdAt?: string;
updatedAt?: string;
}
export const getAccreditationsApi = async (type?: AccreditationType) => {
const query = type ? `?type=${type}` : '';
const res = await apiClient.get(`/accreditation/getAll${query}`);
return res.data;
};
export const getAccreditationApi = async (id: number) => {
const res = await apiClient.get(`/accreditation/${id}`);
return res.data;
};
export const getActiveAccreditationsApi = async (type?: AccreditationType) => {
const query = type ? `?type=${type}` : '';
const res = await apiClient.get(`/accreditation/active${query}`);
return res.data;
};
export const createAccreditationApi = async (data: Partial<Accreditation>) => {
try {
const res = await apiClient.post('/accreditation', data);
toast.success('Accreditation created successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to create accreditation');
throw error;
}
};
export const updateAccreditationApi = async (id: number, data: Partial<Accreditation>) => {
try {
const res = await apiClient.put(`/accreditation/${id}`, data);
toast.success('Accreditation updated successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to update accreditation');
throw error;
}
};
export const deleteAccreditationApi = async (id: number) => {
try {
const res = await apiClient.delete(`/accreditation/${id}`);
toast.success('Accreditation deleted successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to delete accreditation');
throw error;
}
};
+2 -1
View File
@@ -9,6 +9,7 @@ export interface Doctor {
workingStatus?: string;
qualification?: string;
isActive: boolean;
isFeatured: boolean;
globalSortOrder: number;
departments?: {
@@ -53,7 +54,7 @@ export const createDoctorApi = async (data: Doctor) => {
export const updateDoctorApi = async (
doctorId: string,
data: Partial<Doctor>,
action: 'toggleStatus' | 'updateDetails' = 'updateDetails'
action: 'toggleStatus' | 'toggleFeatured' | 'updateDetails' = 'updateDetails'
) => {
try {
const res = await apiClient.patch(`/doctors/${doctorId}/${action}`, data);
+93
View File
@@ -0,0 +1,93 @@
import apiClient from '@/api/client';
import toast from 'react-hot-toast';
export interface SeoData {
seoTitle?: string;
metaDescription?: string;
focusKeyphrase?: string;
slug?: string;
tags?: string[];
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
}
export interface FacilityImage {
id?: number;
url: string;
altText?: string;
description?: string;
}
export interface Facility {
id?: number;
facilityId: string;
name: string;
slug: string;
shortDescription?: string;
description?: string;
videoUrl?: string;
isActive: boolean;
isFeatured: boolean;
sortOrder: number;
departmentId?: number | null;
department?: {
id: number;
departmentId: string;
name: string;
} | null;
images?: FacilityImage[];
seo?: SeoData | null;
}
export const getAllFacilitiesApi = async () => {
const res = await apiClient.get('/facilities/getAll?admin=true');
return res.data;
};
export const getFacilityByIdApi = async (facilityId: string) => {
const res = await apiClient.get(`/facilities/${facilityId}`);
return res.data;
};
export const getFeaturedFacilitiesApi = async () => {
const res = await apiClient.get('/facilities/featured');
return res.data;
};
export const createFacilityApi = async (data: Partial<Facility> & SeoData) => {
try {
const res = await apiClient.post('/facilities', data);
toast.success('Facility created successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to create facility');
throw error;
}
};
export const updateFacilityApi = async (
facilityId: string,
data: Partial<Facility> & SeoData,
action: 'toggleStatus' | 'toggleFeatured' | 'updateDetails' = 'updateDetails'
) => {
try {
const res = await apiClient.patch(`/facilities/${facilityId}/${action}`, data);
toast.success('Facility updated successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to update facility');
throw error;
}
};
export const deleteFacilityApi = async (facilityId: string) => {
try {
const res = await apiClient.delete(`/facilities/${facilityId}`);
toast.success('Facility deleted successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to delete facility');
throw error;
}
};
+58
View File
@@ -0,0 +1,58 @@
import apiClient from '@/api/client';
import toast from 'react-hot-toast';
export interface GoogleReview {
id?: number;
reviewerName: string;
reviewerImage?: string;
rating: number;
review: string;
reviewDate?: string | null;
googleReviewUrl?: string;
isFeatured: boolean;
isActive: boolean;
sortOrder: number;
}
export const getGoogleReviewsApi = async () => {
const res = await apiClient.get('/google-reviews/getAll');
return res.data;
};
export const getActiveGoogleReviewsApi = async () => {
const res = await apiClient.get('/google-reviews/active');
return res.data;
};
export const createGoogleReviewApi = async (data: GoogleReview) => {
try {
const res = await apiClient.post('/google-reviews', data);
toast.success('Google review created successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to create review');
throw error;
}
};
export const updateGoogleReviewApi = async (id: number, data: Partial<GoogleReview>) => {
try {
const res = await apiClient.put(`/google-reviews/${id}`, data);
toast.success('Google review updated successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to update review');
throw error;
}
};
export const deleteGoogleReviewApi = async (id: number) => {
try {
const res = await apiClient.delete(`/google-reviews/${id}`);
toast.success('Google review deleted successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to delete review');
throw error;
}
};
+83
View File
@@ -0,0 +1,83 @@
import apiClient from '@/api/client';
import toast from 'react-hot-toast';
export type BannerMediaType = 'IMAGE' | 'VIDEO';
export interface HomepageBanner {
id?: number;
title?: string;
subtitle?: string;
mediaType: BannerMediaType;
desktopMediaUrl: string;
mobileMediaUrl?: string;
buttonText?: string;
buttonLink?: string;
openInNewTab: boolean;
textAlignment?: 'left' | 'center' | 'right';
sortOrder: number;
isActive: boolean;
createdAt?: string;
updatedAt?: string;
}
export const getHomepageBannersApi = async () => {
const res = await apiClient.get('/homepage-banners/getAll');
return res.data;
};
export const getHomepageBannerApi = async (id: number) => {
const res = await apiClient.get(`/homepage-banners/${id}`);
return res.data;
};
export const getActiveHomepageBannersApi = async () => {
const res = await apiClient.get('/homepage-banners/active');
return res.data;
};
export const createHomepageBannerApi = async (data: Partial<HomepageBanner>) => {
try {
const res = await apiClient.post('/homepage-banners', data);
toast.success('Banner created successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to create banner');
throw error;
}
};
export const updateHomepageBannerApi = async (id: number, data: Partial<HomepageBanner>) => {
try {
const res = await apiClient.put(`/homepage-banners/${id}`, data);
toast.success('Banner updated successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to update banner');
throw error;
}
};
export const deleteHomepageBannerApi = async (id: number) => {
try {
const res = await apiClient.delete(`/homepage-banners/${id}`);
toast.success('Banner deleted successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to delete banner');
throw error;
}
};
+61
View File
@@ -0,0 +1,61 @@
import apiClient from '@/api/client';
import toast from 'react-hot-toast';
export interface InsurancePartner {
id?: number;
name: string;
logo: string;
websiteUrl?: string;
sortOrder: number;
isActive: boolean;
createdAt?: string;
updatedAt?: string;
}
export const getInsurancePartnersApi = async () => {
const res = await apiClient.get('/insurance-partners/getAll');
return res.data;
};
export const getInsurancePartnerApi = async (id: number) => {
const res = await apiClient.get(`/insurance-partners/${id}`);
return res.data;
};
export const getActiveInsurancePartnersApi = async () => {
const res = await apiClient.get('/insurance-partners/active');
return res.data;
};
export const createInsurancePartnerApi = async (data: Partial<InsurancePartner>) => {
try {
const res = await apiClient.post('/insurance-partners', data);
toast.success('Insurance partner created successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to create insurance partner');
throw error;
}
};
export const updateInsurancePartnerApi = async (id: number, data: Partial<InsurancePartner>) => {
try {
const res = await apiClient.put(`/insurance-partners/${id}`, data);
toast.success('Insurance partner updated successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to update insurance partner');
throw error;
}
};
export const deleteInsurancePartnerApi = async (id: number) => {
try {
const res = await apiClient.delete(`/insurance-partners/${id}`);
toast.success('Insurance partner deleted successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to delete insurance partner');
throw error;
}
};
@@ -0,0 +1,209 @@
import { BytescaleUploader } from '@/components/BytescaleUploader/BytescaleUploader';
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 { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
editingAccreditation: any;
accreditationForm: any;
setAccreditationForm: any;
onSave: () => void;
}
export default function AccreditationModal({
open,
onOpenChange,
editingAccreditation,
accreditationForm,
setAccreditationForm,
onSave,
}: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full !max-w-3xl h-auto max-h-[90vh] flex flex-col p-0 overflow-hidden">
{/* Header */}
<DialogHeader className="px-6 py-5 border-b bg-background sticky top-0 z-20">
<DialogTitle className="text-2xl font-bold">
{editingAccreditation ? 'Edit Accreditation / Certification' : 'Create Accreditation / Certification'}
</DialogTitle>
</DialogHeader>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{/* Basic Information */}
<div className="space-y-5">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Basic Information</h3>
<p className="text-sm text-muted-foreground">Configure accreditation or certification details.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-2">
<Label className="font-semibold">Category</Label>
<Select
value={accreditationForm.type}
onValueChange={(value) =>
setAccreditationForm({
...accreditationForm,
type: value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCREDITATION">Accreditation</SelectItem>
<SelectItem value="CERTIFICATION">Certification</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="font-semibold">Title</Label>
<Input
value={accreditationForm.title || ''}
placeholder="e.g. NABH, NABL, ISO 9001"
onChange={(e) =>
setAccreditationForm({
...accreditationForm,
title: e.target.value,
})
}
/>
</div>
</div>
<div className="space-y-2">
<Label className="font-semibold">Description (Optional)</Label>
<Textarea
rows={4}
value={accreditationForm.description || ''}
placeholder="Short description about this accreditation or certificate"
onChange={(e) =>
setAccreditationForm({
...accreditationForm,
description: e.target.value,
})
}
/>
</div>
</div>
{/* Media Upload */}
<div className="space-y-5">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Media Assets</h3>
<p className="text-sm text-muted-foreground">Upload logo and certificate images.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="font-semibold">Logo (Optional)</Label>
<p className="text-xs text-muted-foreground">Recommended transparent PNG/SVG logo.</p>
<BytescaleUploader
value={accreditationForm.logo || ''}
folderPath="/accreditations"
onChange={(url) =>
setAccreditationForm({
...accreditationForm,
logo: url,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">Certificate Image (Optional)</Label>
<p className="text-xs text-muted-foreground">Upload certificate, award or recognition image.</p>
<BytescaleUploader
value={accreditationForm.image || ''}
folderPath="/accreditations"
onChange={(url) =>
setAccreditationForm({
...accreditationForm,
image: url,
})
}
/>
</div>
</div>
</div>
{/* Display Settings */}
<div className="space-y-5">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Display Settings</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-2">
<Label className="font-semibold">Priority</Label>
<Input
type="number"
value={accreditationForm.sortOrder}
onChange={(e) =>
setAccreditationForm({
...accreditationForm,
sortOrder: Number(e.target.value),
})
}
/>
</div>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Active Visibility</p>
</div>
<Switch
checked={accreditationForm.isActive}
onCheckedChange={(value) =>
setAccreditationForm({
...accreditationForm,
isActive: value,
})
}
/>
</div>
</div>
</div>
</div>
{/* Footer */}
<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}>
{editingAccreditation ? 'Save Changes' : 'Create Item'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,263 @@
import { BytescaleUploader } from '@/components/BytescaleUploader/BytescaleUploader';
import { useEffect } from 'react';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
editingBanner: any;
bannerForm: any;
setBannerForm: any;
onSave: () => void;
}
export default function HomepageBannerModal({
open,
onOpenChange,
editingBanner,
bannerForm,
setBannerForm,
onSave,
}: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full !max-w-4xl h-[90vh] 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">
{editingBanner ? 'Edit Homepage Banner' : 'Create Homepage Banner'}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6 space-y-8">
<div className="space-y-5">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Media Configuration</h3>
<p className="text-sm text-muted-foreground">Manage your banner files for desktop and mobile layouts</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="font-semibold">Media Type</Label>
<Select
value={bannerForm.mediaType}
onValueChange={(v) =>
setBannerForm({
...bannerForm,
mediaType: v,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select media type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="IMAGE">Image Asset</SelectItem>
<SelectItem value="VIDEO">Video Loop</SelectItem>
</SelectContent>
</Select>
</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">Publish this banner live on the homepage</p>
</div>
<Switch
checked={bannerForm.isActive}
onCheckedChange={(val) =>
setBannerForm({
...bannerForm,
isActive: val,
})
}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="font-semibold">Desktop Media URL</Label>
<p className="text-xs text-muted-foreground">
{bannerForm.mediaType === 'VIDEO'
? 'Recommended: 1920 × 650 MP4 Format '
: 'Recommended: 1920 × 650 Widescreen'}
</p>
<BytescaleUploader
value={bannerForm.desktopMediaUrl || ''}
folderPath="/homepage-banners"
onChange={(url) =>
setBannerForm({
...bannerForm,
desktopMediaUrl: url,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">Mobile Media URL (Optional)</Label>
<p className="text-xs text-muted-foreground">
{bannerForm.mediaType === 'VIDEO' ? 'Recommended: 340 × 390 MP4 Format' : 'Recommended: 340 × 390 '}
</p>
<BytescaleUploader
value={bannerForm.mobileMediaUrl || ''}
folderPath="/homepage-banners"
onChange={(url) =>
setBannerForm({
...bannerForm,
mobileMediaUrl: url,
})
}
/>
</div>
</div>
</div>
<div className="space-y-5">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Banner Copy & Styles</h3>
<p className="text-sm text-muted-foreground">Modify text details and text alignment configurations</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="font-semibold">Main Heading / Title</Label>
<Input
value={bannerForm.title || ''}
placeholder="e.g., Advanced Healthcare, Exceptional Compassion"
onChange={(e) =>
setBannerForm({
...bannerForm,
title: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">Sub-Heading / Subtitle</Label>
<Input
value={bannerForm.subtitle || ''}
placeholder="e.g., Book appointments online with top multi-specialty doctors."
onChange={(e) =>
setBannerForm({
...bannerForm,
subtitle: e.target.value,
})
}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="font-semibold">Text Alignment Alignment</Label>
<Select
value={bannerForm.textAlignment || 'left'}
onValueChange={(v) =>
setBannerForm({
...bannerForm,
textAlignment: v,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select text position" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">Left Aligned</SelectItem>
<SelectItem value="center">Center Aligned</SelectItem>
<SelectItem value="right">Right Aligned</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="font-semibold">Display Priority Order</Label>
<Input
type="number"
value={bannerForm.sortOrder}
onChange={(e) =>
setBannerForm({
...bannerForm,
sortOrder: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
<div className="space-y-5">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Call To Action (CTA Button)</h3>
<p className="text-sm text-muted-foreground">Hyperlinks for optional button element overlays</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="font-semibold">Button Label</Label>
<Input
value={bannerForm.buttonText || ''}
placeholder="e.g., Find a Doctor"
onChange={(e) =>
setBannerForm({
...bannerForm,
buttonText: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">Button Redirect Link</Label>
<Input
value={bannerForm.buttonLink || ''}
placeholder="e.g., /doctors or https://..."
onChange={(e) =>
setBannerForm({
...bannerForm,
buttonLink: e.target.value,
})
}
/>
</div>
</div>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Target Tab Redirection</p>
<p className="text-sm text-muted-foreground">
Force-launch the button link target into a completely new browser tab window
</p>
</div>
<Switch
checked={bannerForm.openInNewTab}
onCheckedChange={(val) =>
setBannerForm({
...bannerForm,
openInNewTab: val,
})
}
/>
</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}>
{editingBanner ? 'Save Changes' : 'Create Banner'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,12 +1,24 @@
import { useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { User, X, Loader2 } from 'lucide-react';
import { User, X, Loader2, Video } from 'lucide-react';
import axios from 'axios';
interface BytescaleUploaderProps {
value: string;
onChange: (url: string) => void;
folderPath: '/health-packages' | '/seo' | '/doctors' | '/departments' | '/news' | '/blog' | '/doctor-og';
folderPath:
| '/health-packages'
| '/seo'
| '/doctors'
| '/departments'
| '/news'
| '/blog'
| '/doctor-og'
| '/homepage-banners'
| '/insurance-partners'
| '/accreditations'
| '/facilities'
| '/reviews';
}
export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUploaderProps) {
@@ -14,12 +26,19 @@ export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUplo
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const isVideo = (url: string) => {
return /\.(mp4|webm|ogg)$/i.test(url);
};
const onFileSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
alert('File is too large (Max 5MB)');
const maxSize = file.type.startsWith('video/') ? 10 * 1024 * 1024 : 5 * 1024 * 1024;
if (file.size > maxSize) {
alert(file.type.startsWith('video/') ? 'Video is too large (Max 10MB)' : 'Image is too large (Max 5MB)');
return;
}
@@ -44,7 +63,10 @@ export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUplo
alert(`Upload Error: ${errorMessage}`);
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
@@ -54,11 +76,16 @@ export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUplo
<div className="relative">
{value ? (
<>
<img
src={value}
className="w-16 h-16 rounded-full object-cover border-2 border-primary/20"
alt="Preview"
/>
{isVideo(value) ? (
<video src={value} className="w-20 h-20 rounded-md object-cover border-2 border-primary/20" controls />
) : (
<img
src={value}
className="w-16 h-16 rounded-full object-cover border-2 border-primary/20"
alt="Preview"
/>
)}
<button
type="button"
onClick={() => onChange('')}
@@ -82,7 +109,7 @@ export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUplo
type="file"
ref={fileInputRef}
onChange={onFileSelected}
accept="image/jpeg,image/png,image/webp"
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/ogg"
className="hidden"
/>
@@ -99,9 +126,9 @@ export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUplo
Uploading...
</>
) : value ? (
'Change Photo'
'Change File'
) : (
'Upload Photo'
'Upload Image / Video'
)}
</Button>
</div>
@@ -0,0 +1,370 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { BytescaleUploader } from '@/components/BytescaleUploader/BytescaleUploader';
import { Department } from '@/api/department';
import { FacilityImage, Facility } from '@/api/facility';
interface FacilityModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: Facility | null;
form: any;
setForm: any;
departments: Department[];
onSubmit: () => void;
}
export default function FacilityModal({
open,
onOpenChange,
editing,
form,
setForm,
departments,
onSubmit,
}: FacilityModalProps) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
let finalValue: any = type === 'number' ? Number(value) : value;
if (name === 'slug') {
finalValue = finalValue
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '')
.replace(/--+/g, '-');
}
setForm((prev: any) => ({ ...prev, [name]: finalValue }));
};
const handleAddImageField = () => {
setForm((prev: any) => ({
...prev,
images: [...prev.images, { url: '', altText: '', description: '' }],
}));
};
const handleRemoveImageField = (index: number) => {
setForm((prev: any) => ({
...prev,
images: prev.images.filter((_: any, i: number) => i !== index),
}));
};
const handleImageChange = (index: number, field: keyof FacilityImage, value: string) => {
setForm((prev: any) => {
const updatedImages = [...prev.images];
updatedImages[index] = { ...updatedImages[index], [field]: value };
return { ...prev, images: updatedImages };
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full !max-w-5xl h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="p-6 border-b bg-background z-10">
<DialogTitle className="text-2xl">{editing ? 'Edit Facility Record' : 'Add New Facility Asset'}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Left Side: Profile & Structure */}
<div className="space-y-6">
<h3 className="font-bold text-base border-b pb-2">Profile & Structure</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/30">
<Label htmlFor="isActive" className="text-base font-semibold cursor-pointer">
Active
</Label>
<Switch
id="isActive"
checked={form.isActive}
onCheckedChange={(val) => setForm({ ...form, isActive: val })}
/>
</div>
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/30">
<Label htmlFor="isFeatured" className="text-base font-semibold cursor-pointer">
Featured
</Label>
<Switch
id="isFeatured"
checked={form.isFeatured}
onCheckedChange={(val) => setForm({ ...form, isFeatured: val })}
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="sortOrder" className="text-sm font-semibold">
Priority
</Label>
<Input
id="sortOrder"
name="sortOrder"
type="number"
value={form.sortOrder}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">Facility ID</Label>
<Input
name="facilityId"
placeholder="FAC-MRI-3T"
value={form.facilityId}
onChange={handleChange}
disabled={!!editing}
className="text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">Name</Label>
<Input
name="name"
placeholder="3T Digital MRI Center"
value={form.name}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">Connected Department</Label>
<select
name="departmentId"
value={form.departmentId || ''}
onChange={handleChange}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">Select Target Department (Optional)</option>
{departments.map((d) => (
<option key={d.departmentId} value={d.departmentId}>
{d.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">Video URL</Label>
<Input
name="videoUrl"
placeholder="https://youtube.com/watch?v=..."
value={form.videoUrl}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">Brief Summary</Label>
<Textarea
name="shortDescription"
placeholder="Provide a concise introductory snippet statement description..."
value={form.shortDescription}
onChange={handleChange}
className="min-h-[80px] text-base"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">Specifications</Label>
<Textarea
name="description"
placeholder="Write full specifications text information description details here..."
value={form.description}
onChange={handleChange}
className="min-h-[140px] text-base"
/>
</div>
</div>
</div>
<div className="space-y-6">
<h3 className="font-bold text-base border-b pb-2">Image Gallery & Marketing SEO Engine</h3>
<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">Gallery Media Content</p>
<Button type="button" variant="outline" size="sm" onClick={handleAddImageField}>
+ Add Photo
</Button>
</div>
{form.images?.length === 0 ? (
<p className="text-sm text-muted-foreground italic text-center py-4">No gallery items attached.</p>
) : (
<div className="space-y-4 max-h-[300px] overflow-y-auto pr-1">
{form.images.map((img: any, idx: number) => (
<div key={idx} className="border rounded-lg p-4 space-y-3 bg-background relative shadow-sm">
<div className="flex justify-between items-center">
<span className="text-xs font-bold font-mono uppercase tracking-wider text-muted-foreground">
Asset Resource #{idx + 1}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-500 h-7 px-2"
onClick={() => handleRemoveImageField(idx)}
>
Delete
</Button>
</div>
<div className="space-y-2">
<Label className="text-xs">Dynamic Storage Upload CDN Engine Image Target</Label>
<BytescaleUploader
value={img.url}
folderPath="/facilities"
onChange={(url) => handleImageChange(idx, 'url', url)}
/>
</div>
<div className="space-y-1">
<Input
placeholder="Accessibility Alternative Image Text String..."
value={img.altText}
onChange={(e) => handleImageChange(idx, 'altText', e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
))}
</div>
)}
</div>
<div className="space-y-4 p-5 border rounded-md bg-muted/20">
<p className="text-base font-bold">Search Metadata Configurations (SEO)</p>
<div className="space-y-2">
<Label className="text-sm font-semibold">Dynamic Header SEO Title</Label>
<Input
name="seoTitle"
placeholder="Advanced Facility Features Setup..."
value={form.seoTitle}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Engine Meta Crawler Snippet Description</Label>
<Textarea
name="metaDescription"
placeholder="Enter target Google crawler index info context snippet description..."
value={form.metaDescription}
onChange={handleChange}
className="min-h-[80px] text-base"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Focus Core Optimization Target Phrase</Label>
<Input
name="focusKeyphrase"
placeholder="best specialized diagnostic lab facility"
value={form.focusKeyphrase}
onChange={handleChange}
className="text-base"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">URL Custom Route Slug Segment</Label>
<Input
name="slug"
placeholder="advanced-mri-center"
value={form.slug}
onChange={handleChange}
className="text-base"
/>
<p className="text-xs text-muted-foreground font-mono">
Routing Path Output: /facilities/{form.slug || 'slug-placeholder'}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Social Index Keywords Engine Tags</Label>
<div className="flex flex-wrap gap-2 border rounded-md p-3 min-h-[48px] bg-background">
{form.tags?.map((tag: string, i: number) => (
<div
key={i}
className="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm flex items-center gap-2"
>
{tag}
<button
type="button"
onClick={() =>
setForm({ ...form, tags: form.tags.filter((_: string, idx: number) => idx !== i) })
}
>
×
</button>
</div>
))}
<Input
placeholder="Type keyword entity context strings and press Enter"
className="border-0 shadow-none focus-visible:ring-0 min-w-[200px]"
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 className="border-t pt-4 space-y-3">
<p className="text-sm font-bold text-muted-foreground">Social Graph Share Parameters (OG Settings)</p>
<div className="space-y-1">
<Label className="text-xs">OG Share Title</Label>
<Input name="ogTitle" value={form.ogTitle} onChange={handleChange} className="h-9 text-sm" />
</div>
<div className="space-y-1">
<Label className="text-xs">OG Share Summary</Label>
<Textarea
name="ogDescription"
value={form.ogDescription}
onChange={handleChange}
className="min-h-[60px] text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">OG Share Branding Image Media</Label>
<BytescaleUploader
value={form.ogImage}
folderPath="/seo"
onChange={(url) => setForm({ ...form, ogImage: url })}
/>
</div>
</div>
</div>
</div>
</div>
</div>
<DialogFooter className="p-6 border-t bg-background z-10 mt-0">
<Button variant="ghost" onClick={() => onOpenChange(false)} className="text-base">
Cancel
</Button>
<Button onClick={onSubmit} className="px-10 text-base">
{editing ? 'Commit Modifications' : 'Publish Asset Facility'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,209 @@
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { BytescaleUploader } from '@/components/BytescaleUploader/BytescaleUploader';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
editingReview: any;
reviewForm: any;
setReviewForm: any;
onSave: () => void;
}
export default function GoogleReviewModal({
open,
onOpenChange,
editingReview,
reviewForm,
setReviewForm,
onSave,
}: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full !max-w-2xl h-auto max-h-[90vh] 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">
{editingReview ? 'Edit Google Review' : 'Create Google Review'}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Reviewer Information</h3>
<p className="text-sm text-muted-foreground">Details about the customer leaving the review</p>
</div>
<div className="space-y-2">
<Label className="font-semibold">Reviewer Name</Label>
<Input
value={reviewForm.reviewerName || ''}
placeholder="e.g., John Doe"
onChange={(e) =>
setReviewForm({
...reviewForm,
reviewerName: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">Reviewer Profile Picture (Optional)</Label>
<BytescaleUploader
value={reviewForm.reviewerImage || ''}
folderPath="/reviews"
onChange={(url) =>
setReviewForm({
...reviewForm,
reviewerImage: url,
})
}
/>
</div>
</div>
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Review Content</h3>
<p className="text-sm text-muted-foreground">Ratings and testimonial text</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="font-semibold">Rating (1-5)</Label>
<Select
value={String(reviewForm.rating || 5)}
onValueChange={(val) =>
setReviewForm({
...reviewForm,
rating: Number(val),
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select a rating" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 Star</SelectItem>
<SelectItem value="2">2 Stars</SelectItem>
<SelectItem value="3">3 Stars</SelectItem>
<SelectItem value="4">4 Stars</SelectItem>
<SelectItem value="5">5 Stars</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="font-semibold">Review Date (Optional)</Label>
<Input
type="date"
value={reviewForm.reviewDate ? new Date(reviewForm.reviewDate).toISOString().split('T')[0] : ''}
onChange={(e) =>
setReviewForm({
...reviewForm,
reviewDate: e.target.value ? new Date(e.target.value).toISOString() : null,
})
}
/>
</div>
</div>
<div className="space-y-2">
<Label className="font-semibold">Review Message</Label>
<Textarea
rows={4}
value={reviewForm.review || ''}
placeholder="Share the customer experience..."
onChange={(e) =>
setReviewForm({
...reviewForm,
review: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">Original Google Review URL (Optional)</Label>
<Input
value={reviewForm.googleReviewUrl || ''}
placeholder="e.g., https://g.co/kgs/..."
onChange={(e) =>
setReviewForm({
...reviewForm,
googleReviewUrl: e.target.value,
})
}
/>
</div>
</div>
<div className="space-y-4 border-t pt-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="font-semibold">Sorting Rank</Label>
<Input
type="number"
value={reviewForm.sortOrder ?? 1000}
onChange={(e) =>
setReviewForm({
...reviewForm,
sortOrder: Number(e.target.value),
})
}
/>
</div>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30 col-span-1 md:col-span-1">
<div>
<p className="font-semibold text-sm">Featured</p>
</div>
<Switch
checked={!!reviewForm.isFeatured}
onCheckedChange={(val) =>
setReviewForm({
...reviewForm,
isFeatured: val,
})
}
/>
</div>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30 col-span-1 md:col-span-1">
<div>
<p className="font-semibold text-sm">Active Visibility</p>
</div>
<Switch
checked={!!reviewForm.isActive}
onCheckedChange={(val) =>
setReviewForm({
...reviewForm,
isActive: val,
})
}
/>
</div>
</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}>
{editingReview ? 'Save Changes' : 'Add Review'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -120,22 +120,40 @@ export default function HealthPackageModal({
/>
</div>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Active Visibility</p>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Active</p>
<p className="text-sm text-muted-foreground">Show publicly</p>
</div>
<p className="text-sm text-muted-foreground">Show this package publicly</p>
<Switch
checked={pkgForm.isActive}
onCheckedChange={(val) =>
setPkgForm({
...pkgForm,
isActive: val,
})
}
/>
</div>
<Switch
checked={pkgForm.isActive}
onCheckedChange={(val) =>
setPkgForm({
...pkgForm,
isActive: val,
})
}
/>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Featured</p>
<p className="text-sm text-muted-foreground">Show on homepage</p>
</div>
<Switch
checked={pkgForm.isFeatured || false}
onCheckedChange={(val) =>
setPkgForm({
...pkgForm,
isFeatured: val,
})
}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -0,0 +1,132 @@
import { BytescaleUploader } from '@/components/BytescaleUploader/BytescaleUploader';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
editingPartner: any;
partnerForm: any;
setPartnerForm: any;
onSave: () => void;
}
export default function InsurancePartnerModal({
open,
onOpenChange,
editingPartner,
partnerForm,
setPartnerForm,
onSave,
}: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full !max-w-2xl h-auto max-h-[90vh] 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">
{editingPartner ? 'Edit Insurance Partner' : 'Create Insurance Partner'}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Company Information</h3>
<p className="text-sm text-muted-foreground">Setup profile configurations for target insurance brand</p>
</div>
<div className="space-y-2">
<Label className="font-semibold">Company / Provider Name</Label>
<Input
value={partnerForm.name || ''}
placeholder="e.g., National Insurance, Star Health Care"
onChange={(e) =>
setPartnerForm({
...partnerForm,
name: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">Claim Portal Redirect Link (Optional)</Label>
<Input
value={partnerForm.websiteUrl || ''}
placeholder="e.g., https://corporate-claims-portal.com"
onChange={(e) =>
setPartnerForm({
...partnerForm,
websiteUrl: e.target.value,
})
}
/>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label className="font-semibold">Brand Logo Image</Label>
<p className="text-xs text-muted-foreground">
Recommended: Clear layout, transparent background (PNG or SVG preferred)
</p>
<BytescaleUploader
value={partnerForm.logo || ''}
folderPath="/insurance-partners"
onChange={(url) =>
setPartnerForm({
...partnerForm,
logo: url,
})
}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<div className="space-y-2">
<Label className="font-semibold">Grid Layout Sorting Rank</Label>
<Input
type="number"
value={partnerForm.sortOrder}
onChange={(e) =>
setPartnerForm({
...partnerForm,
sortOrder: Number(e.target.value),
})
}
/>
</div>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Active Visibility</p>
</div>
<Switch
checked={partnerForm.isActive}
onCheckedChange={(val) =>
setPartnerForm({
...partnerForm,
isActive: val,
})
}
/>
</div>
</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}>
{editingPartner ? 'Save Changes' : 'Add Partner'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -2,6 +2,8 @@ import { Link, useLocation } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronDown } from 'lucide-react';
export default function Sidebar() {
const location = useLocation();
@@ -51,6 +53,22 @@ export default function Sidebar() {
name: 'Blog',
path: '/blog',
},
{
name: 'Homepage Content',
children: [
{
name: 'Homepage Banner',
path: '/homepage-banner',
},
{
name: 'Insurance Partner',
path: '/insurance-partner',
},
{ name: 'Accreditation', path: '/accreditation' },
{ name: 'Facility', path: '/facility' },
{ name: 'Google Review', path: '/reviews' },
],
},
];
return (
@@ -63,6 +81,35 @@ export default function Sidebar() {
<nav className="p-4 space-y-2">
{navItems.map((item) => {
if ('children' in item) {
const hasActiveChild = item.children.some((child) => location.pathname === child.path);
return (
<Collapsible key={item.name} defaultOpen={hasActiveChild}>
<CollapsibleTrigger asChild>
<Button variant={hasActiveChild ? 'secondary' : 'ghost'} className="w-full justify-between">
<span>{item.name}</span>
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-1 space-y-1">
{item.children.map((child) => {
const active = location.pathname === child.path;
return (
<Link key={child.path} to={child.path}>
<Button variant={active ? 'secondary' : 'ghost'} className="w-full justify-start pl-8">
{child.name}
</Button>
</Link>
);
})}
</CollapsibleContent>
</Collapsible>
);
}
const active = location.pathname === item.path;
return (
@@ -0,0 +1,31 @@
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+347
View File
@@ -0,0 +1,347 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import toast from 'react-hot-toast';
import { AxiosError } from 'axios';
import {
getAccreditationsApi,
createAccreditationApi,
updateAccreditationApi,
deleteAccreditationApi,
Accreditation,
} from '@/api/accreditation';
import AccreditationModal from '@/components/AccreditationModal/AccreditationModal';
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 { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Loader2, RefreshCw, Plus, Pencil, Trash2, Award } from 'lucide-react';
export default function AccreditationPage() {
const [items, setItems] = useState<Accreditation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<Accreditation | null>(null);
const [searchText, setSearchText] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [form, setForm] = useState<Partial<Accreditation>>({
title: '',
type: 'ACCREDITATION',
logo: '',
image: '',
description: '',
sortOrder: 1000,
isActive: true,
});
const fetchData = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await getAccreditationsApi();
setItems(res.data || []);
} catch (err) {
if (err instanceof AxiosError) {
setError(err.response?.data?.message || 'Failed to fetch accreditation records');
} else {
setError('Something went wrong');
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const filteredItems = useMemo(() => {
return items.filter((item) => {
const matchesSearch = item.title.toLowerCase().includes(searchText.toLowerCase());
const matchesCategory = categoryFilter ? item.type === categoryFilter : true;
return matchesSearch && matchesCategory;
});
}, [items, searchText, categoryFilter]);
const handleToggleStatus = async (item: Accreditation) => {
if (!item.id) return;
try {
await updateAccreditationApi(item.id, {
isActive: !item.isActive,
});
toast.success('Status updated');
fetchData();
} catch (err) {
console.error(err);
toast.error('Failed to update status');
}
};
const handleDelete = async (id: number) => {
const confirmDelete = window.confirm('Delete this accreditation permanently?');
if (!confirmDelete) return;
try {
await deleteAccreditationApi(id);
fetchData();
} catch (err) {
console.error(err);
}
};
const openAdd = () => {
setEditingItem(null);
setForm({
title: '',
type: 'ACCREDITATION',
logo: '',
image: '',
description: '',
sortOrder: 1000,
isActive: true,
});
setModalOpen(true);
};
const openEdit = (item: Accreditation) => {
setEditingItem(item);
setForm({
...item,
});
setModalOpen(true);
};
const saveItem = async () => {
if (!form.title?.trim()) {
return toast.error('Title is required');
}
if (!form.type) {
return toast.error('Category is required');
}
try {
if (editingItem?.id) {
const changedFields: Record<string, any> = {};
Object.keys(form).forEach((key) => {
const k = key as keyof Accreditation;
if (JSON.stringify(form[k]) !== JSON.stringify(editingItem[k])) {
changedFields[k] = form[k];
}
});
delete changedFields.id;
delete changedFields.createdAt;
delete changedFields.updatedAt;
if (Object.keys(changedFields).length === 0) {
setModalOpen(false);
return;
}
await updateAccreditationApi(editingItem.id, changedFields);
} else {
await createAccreditationApi(form);
}
setModalOpen(false);
fetchData();
} catch (err) {
console.error(err);
toast.error('Failed to save accreditation');
}
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex flex-col md:flex-row md:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">Accreditations & Certifications</h1>
</div>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search title..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[250px]"
/>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="h-10 rounded-md border px-3"
>
<option value="">All Categories</option>
<option value="ACCREDITATION">Accreditations</option>
<option value="CERTIFICATION">Certifications</option>
</select>
<Button variant="outline" onClick={fetchData} disabled={loading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
<Button onClick={openAdd}>
<Plus className="mr-2 h-4 w-4" />
Add Item
</Button>
</div>
</div>
{error && <div className="p-4 text-red-600 bg-red-50 rounded">{error}</div>}
<Card>
<CardHeader>
<CardTitle>Accreditation Directory</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6">
<div className="border rounded-md overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Order</TableHead>
<TableHead>Logo</TableHead>
<TableHead>Details</TableHead>
<TableHead>Category</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">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>
) : filteredItems.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-10">
No accreditation records found.
</TableCell>
</TableRow>
) : (
filteredItems.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="font-mono">{item.sortOrder}</TableCell>
<TableCell>
<div className="w-20 h-16 border rounded-md overflow-hidden bg-white flex items-center justify-center">
{item.logo ? (
<img src={item.logo} alt={item.title} className="w-full h-full object-contain" />
) : (
<Award className="h-6 w-6 text-muted-foreground" />
)}
</div>
</TableCell>
<TableCell>
<div className="font-semibold">{item.title}</div>
<div className="text-sm text-muted-foreground line-clamp-2">
{item.description || 'No description provided'}
</div>
{item.image && (
<div className="mt-2">
<a
href={item.image}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline"
>
View certificate image
</a>
</div>
)}
</TableCell>
<TableCell>
<Badge variant={item.type === 'ACCREDITATION' ? 'default' : 'secondary'}>{item.type}</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch checked={item.isActive} onCheckedChange={() => handleToggleStatus(item)} />
<Badge variant={item.isActive ? 'default' : 'secondary'}>
{item.isActive ? 'Active' : 'Hidden'}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button size="icon" variant="ghost" onClick={() => openEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-red-500 hover:text-red-600"
onClick={() => item.id && handleDelete(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<AccreditationModal
open={modalOpen}
onOpenChange={setModalOpen}
editingAccreditation={editingItem}
accreditationForm={form}
setAccreditationForm={setForm}
onSave={saveItem}
/>
</div>
);
}
+44 -4
View File
@@ -51,6 +51,7 @@ export default function DoctorPage() {
workingStatus: '',
qualification: '',
isActive: true,
isFeatured: false,
globalSortOrder: 0,
departments: [],
professionalSummary: '',
@@ -156,6 +157,22 @@ export default function DoctorPage() {
}
};
const handleToggleFeatured = async (doc: any) => {
try {
const newFeaturedStatus = !doc.isFeatured;
const payload = {
isFeatured: newFeaturedStatus,
};
await updateDoctorApi(doc.doctorId, payload, 'toggleFeatured');
fetchAll();
} catch (err) {
console.error('Failed to update featured status', err);
}
};
function handleDepartmentToggle(depId: string) {
const exists = form.departments.find((d: any) => d.departmentId === depId);
if (exists) {
@@ -201,6 +218,7 @@ export default function DoctorPage() {
experience: '',
professionalSummary: '',
isActive: true,
isFeatured: false,
globalSortOrder: 0,
specializations: [
{
@@ -235,6 +253,7 @@ export default function DoctorPage() {
workingStatus: doc.workingStatus,
qualification: doc.qualification,
isActive: doc.isActive ?? true,
isFeatured: doc.isFeatured ?? false,
globalSortOrder: doc.globalSortOrder ?? 0,
experience: doc.experience || '',
professionalSummary: doc.professionalSummary || '',
@@ -353,14 +372,15 @@ export default function DoctorPage() {
<CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[1100px] table-fixed border-separate border-spacing-0">
<Table className="w-full min-w-[1200px] 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-[180px] bg-background text-sm font-bold">Doctor Info</TableHead>
<TableHead className="w-[150px] bg-background text-sm font-bold">Designation</TableHead>
<TableHead className="w-[220px] bg-background text-sm font-bold">Departments (Hierarchy)</TableHead>
<TableHead className="w-[80px] bg-background text-sm font-bold">Status (Active)</TableHead>
<TableHead className="w-[100px] bg-background text-sm font-bold">Status (Active)</TableHead>
<TableHead className="w-[100px] bg-background text-sm font-bold">Featured</TableHead>
<TableHead className="w-[80px] bg-background text-right text-sm font-bold">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -368,13 +388,13 @@ export default function DoctorPage() {
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-10">
<TableCell colSpan={7} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : currentItems.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-10 text-base">
<TableCell colSpan={7} className="text-center text-muted-foreground py-10 text-base">
No doctors found
</TableCell>
</TableRow>
@@ -423,6 +443,15 @@ export default function DoctorPage() {
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch checked={doc.isFeatured || false} onCheckedChange={() => handleToggleFeatured(doc)} />
<Badge variant={doc.isFeatured ? 'default' : 'secondary'}>
{doc.isFeatured ? 'Featured' : '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={() => handlePreview(doc)}>
@@ -508,6 +537,17 @@ export default function DoctorPage() {
/>
</div>
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/30">
<Label htmlFor="isFeatured" className="text-base font-semibold cursor-pointer">
Featured
</Label>
<Switch
id="isFeatured"
checked={form.isFeatured}
onCheckedChange={(val) => setForm({ ...form, isFeatured: val })}
/>
</div>
<div className="space-y-1">
<Label htmlFor="globalSortOrder" className="text-sm font-semibold">
Sort Priority (Lower numbers show first)
+406
View File
@@ -0,0 +1,406 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import toast from 'react-hot-toast';
import { AxiosError } from 'axios';
import { Eye, Plus, Pencil, Loader2, RefreshCw, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
import { getAllFacilitiesApi, createFacilityApi, updateFacilityApi, deleteFacilityApi, Facility } from '@/api/facility';
import { getDepartmentsApi, Department } from '@/api/department';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import SeoPreview from '@/components/SeoPreview/SeoPreview';
import FacilityModal from '@/components/FacilityModal/FacilityModal';
export default function FacilityPage() {
const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL;
const [facilities, setFacilities] = useState<Facility[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [openModal, setOpenModal] = useState(false);
const [editing, setEditing] = useState<Facility | null>(null);
const [openSeoPreview, setOpenSeoPreview] = useState(false);
const [previewFacility, setPreviewFacility] = useState<any>(null);
const [searchText, setSearchText] = useState('');
const [filterDepartment, setFilterDepartment] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [form, setForm] = useState<any>({
facilityId: '',
name: '',
slug: '',
shortDescription: '',
description: '',
videoUrl: '',
isActive: true,
isFeatured: false,
sortOrder: 1000,
departmentId: '',
images: [],
seoTitle: '',
metaDescription: '',
focusKeyphrase: '',
tags: [],
ogTitle: '',
ogDescription: '',
ogImage: '',
});
const fetchData = useCallback(async () => {
setLoading(true);
setError('');
try {
const [facRes, depRes] = await Promise.all([getAllFacilitiesApi(), getDepartmentsApi()]);
setFacilities(facRes?.data || []);
setDepartments(depRes?.data || []);
} catch (err) {
if (err instanceof AxiosError) {
setError(err.response?.data?.message || 'Failed to load system facilities');
} else {
setError('An unexpected system error occurred');
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const filteredFacilities = useMemo(() => {
return facilities.filter((fac) => {
const matchesSearch =
fac.name.toLowerCase().includes(searchText.toLowerCase()) ||
fac.facilityId.toLowerCase().includes(searchText.toLowerCase());
const matchesDept = filterDepartment
? fac.department?.id.toString() === filterDepartment || fac.department?.departmentId === filterDepartment
: true;
return matchesSearch && matchesDept;
});
}, [facilities, searchText, filterDepartment]);
useEffect(() => {
setCurrentPage(1);
}, [searchText, filterDepartment]);
const totalPages = Math.ceil(filteredFacilities.length / itemsPerPage);
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = filteredFacilities.slice(indexOfFirstItem, indexOfLastItem);
const handleToggleStatus = async (fac: Facility) => {
try {
await updateFacilityApi(fac.facilityId, { isActive: !fac.isActive }, 'toggleStatus');
toast.success(`Facility ${fac.isActive ? 'hidden' : 'activated'} successfully`);
fetchData();
} catch (err) {
console.error(err);
}
};
const handleToggleFeatured = async (fac: Facility) => {
try {
await updateFacilityApi(fac.facilityId, { isFeatured: !fac.isFeatured }, 'toggleFeatured');
toast.success(`Facility status modified successfully`);
fetchData();
} catch (err) {
console.error(err);
}
};
const openAdd = () => {
setEditing(null);
setForm({
facilityId: '',
name: '',
slug: '',
shortDescription: '',
description: '',
videoUrl: '',
isActive: true,
isFeatured: false,
sortOrder: 1000,
departmentId: '',
images: [],
seoTitle: '',
metaDescription: '',
focusKeyphrase: '',
tags: [],
ogTitle: '',
ogDescription: '',
ogImage: '',
});
setOpenModal(true);
};
const openEdit = async (fac: Facility) => {
setEditing(fac);
setForm({
facilityId: fac.facilityId,
name: fac.name,
shortDescription: fac.shortDescription || '',
description: fac.description || '',
videoUrl: fac.videoUrl || '',
isActive: fac.isActive,
isFeatured: fac.isFeatured,
sortOrder: fac.sortOrder,
departmentId: fac.department?.departmentId || '',
images:
fac.images?.map((img) => ({
url: img.url,
altText: img.altText || '',
description: img.description || '',
})) || [],
seoTitle: fac.seo?.seoTitle || '',
metaDescription: fac.seo?.metaDescription || '',
focusKeyphrase: fac.seo?.focusKeyphrase || '',
slug: fac.seo?.slug || fac.slug,
tags: fac.seo?.tags || [],
ogTitle: fac.seo?.ogTitle || '',
ogDescription: fac.seo?.ogDescription || '',
ogImage: fac.seo?.ogImage || '',
});
setOpenModal(true);
};
const handleDelete = async (facilityId: string) => {
if (
!window.confirm(
'Are you entirely sure you want to delete this facility profile? This structural shift cannot be reversed.'
)
)
return;
try {
await deleteFacilityApi(facilityId);
fetchData();
} catch (err) {
console.error(err);
}
};
const handlePreview = (fac: Facility) => {
setPreviewFacility(fac);
setOpenSeoPreview(true);
};
const handleSubmit = async () => {
if (!form.facilityId) return toast.error('Facility ID signature is required');
if (!form.name?.trim()) return toast.error('Facility Name string configuration is required');
if (!form.slug?.trim()) return toast.error('Valid clean URL slug structure is required');
try {
const payload = {
...form,
departmentId: form.departmentId && form.departmentId !== '' ? form.departmentId : null,
};
if (editing) {
await updateFacilityApi(editing.facilityId, payload, 'updateDetails');
} else {
await createFacilityApi(payload);
}
setOpenModal(false);
fetchData();
} catch (error) {
console.error(error);
}
};
const getFacilityUrl = (fac: any) => {
return `${WEBSITE_URL}/facilities/${fac?.slug || fac?.facilityId}`;
};
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">Medical & Hospital Facilities</h1>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search facility profiles..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[250px] text-base"
/>
<select
value={filterDepartment}
onChange={(e) => setFilterDepartment(e.target.value)}
className="flex h-10 w-[220px] rounded-md border border-input bg-background px-3 py-2 text-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">All Departments</option>
{departments.map((dep) => (
<option key={dep.departmentId} value={dep.departmentId}>
{dep.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={openAdd} className="text-base">
<Plus className="mr-2 h-5 w-5" />
Add Facility
</Button>
</div>
</div>
{error && <div className="p-4 text-red-600 bg-red-50 border rounded-md text-base">{error}</div>}
<Card>
<CardHeader>
<CardTitle className="text-xl">Facilities Portfolio</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[1100px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow>
<TableHead className="w-[80px] bg-background text-sm font-bold">Order</TableHead>
<TableHead className="w-[250px] bg-background text-sm font-bold">Facility Name</TableHead>
<TableHead className="w-[180px] bg-background text-sm font-bold">Linked Department</TableHead>
<TableHead className="w-[120px] bg-background text-sm font-bold">Status (Active)</TableHead>
<TableHead className="w-[100px] bg-background text-sm font-bold">Featured</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 explicit healthcare or asset facilities found.
</TableCell>
</TableRow>
) : (
currentItems.map((fac) => (
<TableRow key={fac.facilityId} className="hover:bg-muted/50">
<TableCell className="font-mono text-sm">{fac.sortOrder}</TableCell>
<TableCell>
<div className="font-semibold text-base truncate" title={fac.name}>
{fac.name}
</div>
<div className="text-xs text-muted-foreground font-mono truncate mt-0.5">/{fac.slug}</div>
</TableCell>
<TableCell>
{fac.department ? (
<Badge variant="secondary" className="text-xs">
{fac.department.name}
</Badge>
) : (
<span className="text-xs text-muted-foreground italic">None Assigned</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch checked={fac.isActive} onCheckedChange={() => handleToggleStatus(fac)} />
<Badge variant={fac.isActive ? 'default' : 'secondary'}>
{fac.isActive ? 'Active' : 'Hidden'}
</Badge>
</div>
</TableCell>
<TableCell>
<Switch checked={fac.isFeatured} onCheckedChange={() => handleToggleFeatured(fac)} />
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button size="icon" variant="ghost" className="h-9 w-9" onClick={() => handlePreview(fac)}>
<Eye className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" className="h-9 w-9" onClick={() => openEdit(fac)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handleDelete(fac.facilityId)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && filteredFacilities.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, filteredFacilities.length)}</span> of{' '}
<span className="font-semibold">{filteredFacilities.length}</span> entries
</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((p) => Math.max(p - 1, 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
disabled={currentPage === totalPages || totalPages === 0}
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<FacilityModal
open={openModal}
onOpenChange={setOpenModal}
editing={editing}
form={form}
setForm={setForm}
departments={departments}
onSubmit={handleSubmit}
/>
<SeoPreview
open={openSeoPreview}
onOpenChange={setOpenSeoPreview}
previewData={previewFacility}
url={getFacilityUrl(previewFacility)}
/>
</div>
);
}
+326
View File
@@ -0,0 +1,326 @@
import { useState, useEffect, useCallback } from 'react';
import toast from 'react-hot-toast';
import { AxiosError } from 'axios';
import {
getGoogleReviewsApi,
createGoogleReviewApi,
updateGoogleReviewApi,
deleteGoogleReviewApi,
GoogleReview,
} from '@/api/googleReview';
import GoogleReviewModal from '@/components/GoogleReviewModal/GoogleReviewModal';
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 { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Loader2, RefreshCw, Plus, Pencil, Trash2, ExternalLink, Star } from 'lucide-react';
export default function GoogleReviewPage() {
const [reviews, setReviews] = useState<GoogleReview[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [reviewModal, setReviewModal] = useState(false);
const [editingReview, setEditingReview] = useState<GoogleReview | null>(null);
const [searchText, setSearchText] = useState('');
const [reviewForm, setReviewForm] = useState<Partial<GoogleReview>>({
reviewerName: '',
reviewerImage: '',
rating: 5,
review: '',
reviewDate: null,
googleReviewUrl: '',
isFeatured: false,
isActive: true,
sortOrder: 1000,
});
const fetchData = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await getGoogleReviewsApi();
setReviews(res.data || []);
} catch (err) {
if (err instanceof AxiosError) {
setError(err.response?.data?.message || 'Failed to sync Google review records.');
} else {
setError('An unhandled database communication error occurred.');
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleToggleStatus = async (review: GoogleReview) => {
if (review.id === undefined) return;
try {
await updateGoogleReviewApi(review.id, { isActive: !review.isActive });
toast.success(`Review status updated successfully`);
fetchData();
} catch (err) {
console.error(err);
}
};
const handleToggleFeatured = async (review: GoogleReview) => {
if (review.id === undefined) return;
try {
await updateGoogleReviewApi(review.id, { isFeatured: !review.isFeatured });
toast.success(`Review featured status updated`);
fetchData();
} catch (err) {
console.error(err);
}
};
const handleDeleteReview = async (id: number) => {
const confirmDelete = window.confirm(
'Are you completely sure you want to remove this testimonial record? This step is irreversible.'
);
if (!confirmDelete) return;
try {
await deleteGoogleReviewApi(id);
fetchData();
} catch (err) {
console.error(err);
}
};
const openAddReview = () => {
setEditingReview(null);
setReviewForm({
reviewerName: '',
reviewerImage: '',
rating: 5,
review: '',
reviewDate: null,
googleReviewUrl: '',
isFeatured: false,
isActive: true,
sortOrder: 1000,
});
setReviewModal(true);
};
const openEditReview = (review: GoogleReview) => {
setEditingReview(review);
setReviewForm({ ...review });
setReviewModal(true);
};
const saveReview = async () => {
if (!reviewForm.reviewerName) return toast.error('Reviewer name is required.');
if (!reviewForm.review) return toast.error('Review message content is required.');
try {
const finalData = { ...reviewForm };
if (editingReview?.id) {
const changedFields: Record<string, any> = {};
Object.keys(finalData).forEach((key) => {
const k = key as keyof GoogleReview;
if (JSON.stringify(finalData[k]) !== JSON.stringify(editingReview[k])) {
changedFields[k] = finalData[k];
}
});
delete changedFields.id;
if (Object.keys(changedFields).length === 0) {
setReviewModal(false);
return;
}
await updateGoogleReviewApi(editingReview.id, changedFields);
} else {
await createGoogleReviewApi(finalData as GoogleReview);
}
setReviewModal(false);
fetchData();
} catch (err) {
console.error(err);
}
};
const filteredReviews = reviews.filter((r) => r.reviewerName.toLowerCase().includes(searchText.toLowerCase()));
return (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<div>
<h1 className="text-3xl font-bold">Google Reviews</h1>
</div>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search reviews by name..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[260px] text-base"
/>
<Button variant="outline" onClick={fetchData} disabled={loading} className="text-base">
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={openAddReview} className="text-base">
<Plus className="mr-2 h-5 w-5" />
Add Review
</Button>
</div>
</div>
{error && <div className="p-4 text-red-600 bg-red-50 border rounded-md text-base">{error}</div>}
<Card>
<CardHeader>
<CardTitle className="text-xl">Testimonials Sequence Directory</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[680px] relative">
<Table className="w-full min-w-[900px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow>
<TableHead className="w-[70px] bg-background text-sm font-bold">Order</TableHead>
<TableHead className="w-[110px] bg-background text-sm font-bold">Avatar</TableHead>
<TableHead className="w-[170px] bg-background text-sm font-bold">Reviewer</TableHead>
<TableHead className="w-[90px] bg-background text-sm font-bold">Rating</TableHead>
<TableHead className="w-[260px] bg-background text-sm font-bold">Testimonial</TableHead>
<TableHead className="w-[110px] bg-background text-sm font-bold">Source Link</TableHead>
<TableHead className="w-[100px] bg-background text-sm font-bold">Featured</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>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : filteredReviews.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground py-10 text-base">
No reviews found matching your criteria.
</TableCell>
</TableRow>
) : (
filteredReviews.map((review) => (
<TableRow key={review.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-sm">{review.sortOrder}</TableCell>
<TableCell>
{review.reviewerImage ? (
<img
src={review.reviewerImage}
alt={review.reviewerName}
className="w-10 h-10 rounded-full object-cover border"
/>
) : (
<div className="w-10 h-10 rounded-full bg-secondary flex items-center justify-center border text-muted-foreground font-bold">
{review.reviewerName.charAt(0).toUpperCase()}
</div>
)}
</TableCell>
<TableCell>
<div className="font-semibold text-base truncate" title={review.reviewerName}>
{review.reviewerName}
</div>
{review.reviewDate && (
<div className="text-xs text-muted-foreground">
{new Date(review.reviewDate).toLocaleDateString()}
</div>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 font-medium text-sm">
{review.rating}
<Star className="h-4 w-4 fill-amber-400 text-amber-500" />
</div>
</TableCell>
<TableCell>
<div className="text-sm truncate max-w-[240px]" title={review.review}>
"{review.review}"
</div>
</TableCell>
<TableCell>
{review.googleReviewUrl ? (
<a
href={review.googleReviewUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-sky-600 hover:underline flex items-center gap-1"
>
Link <ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="text-xs text-muted-foreground italic">-</span>
)}
</TableCell>
<TableCell>
<Switch checked={review.isFeatured} onCheckedChange={() => handleToggleFeatured(review)} />
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch checked={review.isActive} onCheckedChange={() => handleToggleStatus(review)} />
<Badge variant={review.isActive ? 'default' : 'secondary'}>
{review.isActive ? 'Active' : 'Disabled'}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => openEditReview(review)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-red-500 hover:text-red-600"
onClick={() => review.id && handleDeleteReview(review.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<GoogleReviewModal
open={reviewModal}
onOpenChange={setReviewModal}
editingReview={editingReview}
reviewForm={reviewForm}
setReviewForm={setReviewForm}
onSave={saveReview}
/>
</div>
);
}
+14
View File
@@ -62,6 +62,7 @@ export default function HealthPackagePage() {
discountedPrice: undefined,
categoryId: 0,
isActive: true,
isFeatured: false,
sortOrder: 1000,
seo: {
seoTitle: '',
@@ -167,6 +168,7 @@ export default function HealthPackagePage() {
discountedPrice: undefined,
categoryId: categories[0]?.id || 0,
isActive: true,
isFeatured: false,
sortOrder: 1000,
seo: {
seoTitle: '',
@@ -366,6 +368,7 @@ export default function HealthPackagePage() {
<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-[100px] bg-background text-sm font-bold">Featured</TableHead>
<TableHead className="w-[120px] bg-background text-right text-sm font-bold">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -418,6 +421,17 @@ export default function HealthPackagePage() {
</Badge>
</div>
</TableCell>
<TableCell>
<Switch
checked={pkg.isFeatured}
onCheckedChange={async () => {
await updateHealthPackageApi(pkg.id!, {
isFeatured: !pkg.isFeatured,
});
fetchData();
}}
/>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
+351
View File
@@ -0,0 +1,351 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import toast from 'react-hot-toast';
import { AxiosError } from 'axios';
import {
getHomepageBannersApi,
createHomepageBannerApi,
updateHomepageBannerApi,
deleteHomepageBannerApi,
HomepageBanner,
} from '@/api/homepageBanner';
import HomepageBannerModal from '@/components/BannerModal/HomepageBannerModal';
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 { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Loader2, RefreshCw, Plus, Pencil, Trash2, ExternalLink, Image, Video } from 'lucide-react';
export default function HomepageBannerPage() {
const [banners, setBanners] = useState<HomepageBanner[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [bannerModal, setBannerModal] = useState(false);
const [editingBanner, setEditingBanner] = useState<HomepageBanner | null>(null);
const [searchText, setSearchText] = useState('');
const [filterMediaType, setFilterMediaType] = useState('');
const [bannerForm, setBannerForm] = useState<Partial<HomepageBanner>>({
title: '',
subtitle: '',
mediaType: 'IMAGE',
desktopMediaUrl: '',
mobileMediaUrl: '',
buttonText: '',
buttonLink: '',
openInNewTab: false,
textAlignment: 'left',
sortOrder: 1000,
isActive: true,
});
const fetchData = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await getHomepageBannersApi();
setBanners(res.data || []);
} catch (err) {
if (err instanceof AxiosError) {
setError(err.response?.data?.message || 'Failed to sync banner directory records.');
} else {
setError('An unhandled database communication error occurred.');
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const filteredBanners = useMemo(() => {
return banners.filter((banner) => {
const matchesSearch =
(banner.title?.toLowerCase().includes(searchText.toLowerCase()) ||
banner.subtitle?.toLowerCase().includes(searchText.toLowerCase()) ||
banner.buttonText?.toLowerCase().includes(searchText.toLowerCase())) ??
true;
const matchesType = filterMediaType ? banner.mediaType === filterMediaType : true;
return matchesSearch && matchesType;
});
}, [banners, searchText, filterMediaType]);
const handleToggleStatus = async (banner: HomepageBanner) => {
if (!banner.id) return;
try {
await updateHomepageBannerApi(banner.id, { isActive: !banner.isActive });
toast.success(`Banner display ${banner.isActive ? 'hidden from production' : 'activated live'}`);
fetchData();
} catch (err) {
console.error(err);
toast.error('Could not overwrite structural runtime configuration details.');
}
};
const handleDeleteBanner = async (id: number) => {
const confirmDelete = window.confirm(
'Are you absolutely sure you want to delete this home banner permanently? This cannot be undone.'
);
if (!confirmDelete) return;
try {
await deleteHomepageBannerApi(id);
fetchData();
} catch (err) {
console.error(err);
}
};
const openAddBanner = () => {
setEditingBanner(null);
setBannerForm({
title: '',
subtitle: '',
mediaType: 'IMAGE',
desktopMediaUrl: '',
mobileMediaUrl: '',
buttonText: '',
buttonLink: '',
openInNewTab: false,
textAlignment: 'left',
sortOrder: 1000,
isActive: true,
});
setBannerModal(true);
};
const openEditBanner = (banner: HomepageBanner) => {
setEditingBanner(banner);
setBannerForm({ ...banner });
setBannerModal(true);
};
const saveBanner = async () => {
if (!bannerForm.desktopMediaUrl) return toast.error('Desktop Media Asset is required.');
if (!bannerForm.mediaType) return toast.error('A valid media type assignment rule must be explicitly passed.');
try {
const finalData = { ...bannerForm };
if (editingBanner?.id) {
const changedFields: Record<string, any> = {};
Object.keys(finalData).forEach((key) => {
const k = key as keyof HomepageBanner;
if (JSON.stringify(finalData[k]) !== JSON.stringify(editingBanner[k])) {
changedFields[k] = finalData[k];
}
});
delete changedFields.id;
delete changedFields.createdAt;
delete changedFields.updatedAt;
if (Object.keys(changedFields).length === 0) {
setBannerModal(false);
return;
}
await updateHomepageBannerApi(editingBanner.id, changedFields);
} else {
await createHomepageBannerApi(finalData);
}
setBannerModal(false);
fetchData();
} catch (err) {
console.error(err);
toast.error('An unexpected runtime service error halted banner save operations.');
}
};
return (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<div>
<h1 className="text-3xl font-bold">Homepage Banners</h1>
</div>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search banners text contents..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[260px] text-base"
/>
<select
value={filterMediaType}
onChange={(e) => setFilterMediaType(e.target.value)}
className="flex h-10 w-[180px] 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"
>
<option value="">All Media Types</option>
<option value="IMAGE">Images Only</option>
<option value="VIDEO">Videos Only</option>
</select>
<Button variant="outline" onClick={fetchData} disabled={loading} className="text-base">
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={openAddBanner} className="text-base">
<Plus className="mr-2 h-5 w-5" />
Add Banner
</Button>
</div>
</div>
{error && <div className="p-4 text-red-600 bg-red-50 border rounded-md text-base">{error}</div>}
<Card>
<CardHeader>
<CardTitle className="text-xl">Active Slide Queue Sequence</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[680px] 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-[140px] bg-background text-sm font-bold">Media Preview</TableHead>
<TableHead className="w-[280px] bg-background text-sm font-bold">Banner Details</TableHead>
<TableHead className="w-[200px] bg-background text-sm font-bold">CTA Button Action</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>
) : filteredBanners.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-10 text-base">
No homepage hero media elements configured inside database matching selection rules.
</TableCell>
</TableRow>
) : (
filteredBanners.map((banner) => (
<TableRow key={banner.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-sm">{banner.sortOrder}</TableCell>
<TableCell>
<div className="w-24 h-14 rounded-md overflow-hidden bg-muted relative border flex items-center justify-center">
{banner.mediaType === 'IMAGE' ? (
<img
src={banner.desktopMediaUrl}
alt="Banner layout"
className="w-full h-full object-cover"
/>
) : (
<div className="flex flex-col items-center justify-center text-muted-foreground text-[10px]">
<Video className="h-4 w-4 mb-0.5 text-primary" />
Video Loop
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="font-semibold text-base truncate" title={banner.title || 'Untitled Banner'}>
{banner.title || (
<span className="text-muted-foreground italic text-sm">No Heading text</span>
)}
</div>
<div className="text-xs text-muted-foreground truncate mt-0.5 max-w-[260px]">
{banner.subtitle || 'No body text description supplied.'}
</div>
<div className="flex items-center gap-2 mt-1.5">
<Badge variant="outline" className="text-[10px] uppercase tracking-wider px-1.5 py-0">
{banner.mediaType === 'IMAGE' ? (
<Image className="h-3 w-3 inline mr-1" />
) : (
<Video className="h-3 w-3 inline mr-1" />
)}
{banner.mediaType}
</Badge>
<Badge variant="secondary" className="text-[10px] capitalize px-1.5 py-0">
Align: {banner.textAlignment}
</Badge>
</div>
</TableCell>
<TableCell>
{banner.buttonText ? (
<div className="space-y-1">
<span className="inline-block text-xs font-semibold px-2 py-0.5 bg-primary/10 text-primary border rounded-md">
{banner.buttonText}
</span>
<div
className="text-xs text-muted-foreground font-mono truncate max-w-[180px]"
title={banner.buttonLink}
>
{banner.buttonLink}
</div>
{banner.openInNewTab && (
<span className="text-[10px] text-sky-600 flex items-center gap-0.5">
<ExternalLink className="h-2.5 w-2.5" /> Opens new window
</span>
)}
</div>
) : (
<span className="text-xs text-muted-foreground italic">Static Layout (No CTA link)</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch checked={banner.isActive} onCheckedChange={() => handleToggleStatus(banner)} />
<Badge variant={banner.isActive ? 'default' : 'secondary'}>
{banner.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 text-muted-foreground hover:text-foreground"
onClick={() => openEditBanner(banner)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => banner.id && handleDeleteBanner(banner.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<HomepageBannerModal
open={bannerModal}
onOpenChange={setBannerModal}
editingBanner={editingBanner}
bannerForm={bannerForm}
setBannerForm={setBannerForm}
onSave={saveBanner}
/>
</div>
);
}
+285
View File
@@ -0,0 +1,285 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import toast from 'react-hot-toast';
import { AxiosError } from 'axios';
import {
getInsurancePartnersApi,
createInsurancePartnerApi,
updateInsurancePartnerApi,
deleteInsurancePartnerApi,
InsurancePartner,
} from '@/api/insurancePartner';
import InsurancePartnerModal from '@/components/InsurancePartnerModal/InsurancePartnerModal';
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 { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Loader2, RefreshCw, Plus, Pencil, Trash2, Link2 } from 'lucide-react';
export default function InsurancePartnerPage() {
const [partners, setPartners] = useState<InsurancePartner[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [partnerModal, setPartnerModal] = useState(false);
const [editingPartner, setEditingPartner] = useState<InsurancePartner | null>(null);
const [searchText, setSearchText] = useState('');
const [partnerForm, setPartnerForm] = useState<Partial<InsurancePartner>>({
name: '',
logo: '',
websiteUrl: '',
sortOrder: 1000,
isActive: true,
});
const fetchData = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await getInsurancePartnersApi();
setPartners(res.data || []);
} catch (err) {
if (err instanceof AxiosError) {
setError(err.response?.data?.message || 'Failed to sync insurance directory records.');
} else {
setError('An unhandled database communication error occurred.');
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleToggleStatus = async (partner: InsurancePartner) => {
if (!partner.id) return;
try {
await updateInsurancePartnerApi(partner.id, { isActive: !partner.isActive });
toast.success(`Partner status updated successfully`);
fetchData();
} catch (err) {
console.error(err);
toast.error('Could not overwrite corporate status configurations.');
}
};
const handleDeletePartner = async (id: number) => {
const confirmDelete = window.confirm(
'Are you completely sure you want to remove this insurance partner record? This step is irreversible.'
);
if (!confirmDelete) return;
try {
await deleteInsurancePartnerApi(id);
fetchData();
} catch (err) {
console.error(err);
}
};
const openAddPartner = () => {
setEditingPartner(null);
setPartnerForm({
name: '',
logo: '',
websiteUrl: '',
sortOrder: 1000,
isActive: true,
});
setPartnerModal(true);
};
const openEditPartner = (partner: InsurancePartner) => {
setEditingPartner(partner);
setPartnerForm({ ...partner });
setPartnerModal(true);
};
const savePartner = async () => {
if (!partnerForm.name) return toast.error('Insurance Partner Name is required.');
if (!partnerForm.logo) return toast.error('Partner Corporate Logo Asset is required.');
try {
const finalData = { ...partnerForm };
if (editingPartner?.id) {
const changedFields: Record<string, any> = {};
Object.keys(finalData).forEach((key) => {
const k = key as keyof InsurancePartner;
if (JSON.stringify(finalData[k]) !== JSON.stringify(editingPartner[k])) {
changedFields[k] = finalData[k];
}
});
delete changedFields.id;
delete changedFields.createdAt;
delete changedFields.updatedAt;
if (Object.keys(changedFields).length === 0) {
setPartnerModal(false);
return;
}
await updateInsurancePartnerApi(editingPartner.id, changedFields);
} else {
await createInsurancePartnerApi(finalData);
}
setPartnerModal(false);
fetchData();
} catch (err) {
console.error(err);
toast.error('An unexpected service exception halted corporate resource persistence runtime.');
}
};
return (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<div>
<h1 className="text-3xl font-bold">Insurance Partners</h1>
</div>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search partners by name..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[260px] text-base"
/>
<Button variant="outline" onClick={fetchData} disabled={loading} className="text-base">
<RefreshCw className="mr-2 h-5 w-5" />
Refresh
</Button>
<Button onClick={openAddPartner} className="text-base">
<Plus className="mr-2 h-5 w-5" />
Add Partner
</Button>
</div>
</div>
{error && <div className="p-4 text-red-600 bg-red-50 border rounded-md text-base">{error}</div>}
<Card>
<CardHeader>
<CardTitle className="text-xl">Network Panels Sequence Directory</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[680px] relative">
<Table className="w-full min-w-[800px] 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">Order</TableHead>
<TableHead className="w-[160px] bg-background text-sm font-bold">Logo Preview</TableHead>
<TableHead className="w-[260px] bg-background text-sm font-bold">Company Identity</TableHead>
<TableHead className="w-[240px] bg-background text-sm font-bold">Portal Website</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>
) : partners.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-10 text-base">
No active healthcare insurance tie-ups found matching criteria.
</TableCell>
</TableRow>
) : (
partners.map((partner) => (
<TableRow key={partner.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-sm">{partner.sortOrder}</TableCell>
<TableCell>
<div className="w-28 h-14 rounded-md overflow-hidden bg-white p-1 relative border flex items-center justify-center">
<img
src={partner.logo}
alt={`${partner.name} logo`}
className="w-full h-full object-contain"
/>
</div>
</TableCell>
<TableCell>
<div className="font-semibold text-base truncate" title={partner.name}>
{partner.name}
</div>
</TableCell>
<TableCell>
{partner.websiteUrl ? (
<a
href={partner.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-sky-600 hover:underline flex items-center gap-1.5 truncate max-w-[220px]"
title={partner.websiteUrl}
>
<Link2 className="h-3.5 w-3.5 flex-shrink-0" />
{partner.websiteUrl}
</a>
) : (
<span className="text-xs text-muted-foreground italic">No external URL linked</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch checked={partner.isActive} onCheckedChange={() => handleToggleStatus(partner)} />
<Badge variant={partner.isActive ? 'default' : 'secondary'}>
{partner.isActive ? 'Active' : 'Disabled'}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={() => openEditPartner(partner)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => partner.id && handleDeletePartner(partner.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<InsurancePartnerModal
open={partnerModal}
onOpenChange={setPartnerModal}
editingPartner={editingPartner}
partnerForm={partnerForm}
setPartnerForm={setPartnerForm}
onSave={savePartner}
/>
</div>
);
}