Compare commits

..

12 Commits

33 changed files with 2913 additions and 36 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;
+65
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
@@ -314,3 +315,67 @@ 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
}
+6
View File
@@ -16,6 +16,9 @@ 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';
dotenv.config();
@@ -57,6 +60,9 @@ 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);
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',
});
}
};
@@ -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);
+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;
+6
View File
@@ -24,6 +24,9 @@ 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';
export default function App() {
return (
@@ -53,6 +56,9 @@ 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>
</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);
+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,22 @@
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';
}
export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUploaderProps) {
@@ -14,12 +24,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 +61,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 +74,16 @@ export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUplo
<div className="relative">
{value ? (
<>
{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 +107,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 +124,9 @@ export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUplo
Uploading...
</>
) : value ? (
'Change Photo'
'Change File'
) : (
'Upload Photo'
'Upload Image / Video'
)}
</Button>
</div>
@@ -120,11 +120,11 @@ export default function HealthPackageModal({
/>
</div>
<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 Visibility</p>
<p className="text-sm text-muted-foreground">Show this package publicly</p>
<p className="font-semibold">Active</p>
<p className="text-sm text-muted-foreground">Show publicly</p>
</div>
<Switch
@@ -138,6 +138,24 @@ 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">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">
<div className="space-y-2">
<Label className="font-semibold">Package Name</Label>
@@ -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,20 @@ 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' },
],
},
];
return (
@@ -63,6 +79,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)
+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>
);
}