Compare commits

..

12 Commits

Author SHA1 Message Date
Kailasdevdas 8ac199103a fix: resolve Prisma relation error in health package creation/update 2026-07-14 17:15:50 +05:30
kailasdevdas 826c802962 Merge pull request 'feat: add dual-date filter' (#53) from feat/appointment-filter into dev
Reviewed-on: #53
2026-07-02 11:30:46 +00:00
Kailasdevdas 37b86f578e feat: add dual-date filter 2026-07-02 16:48:25 +05:30
kailasdevdas d125165d3e Merge pull request 'feat: show recommended size' (#52) from feat/facility-google-review-crud into dev
Reviewed-on: #52
2026-06-30 03:58:16 +00:00
Kailasdevdas 316eec7c47 feat: show recommended size 2026-06-30 09:26:48 +05:30
kailasdevdas 20438ed3de Merge pull request 'feat: facility and google review crud' (#51) from feat/facility-google-review-crud into dev
Reviewed-on: #51
2026-06-29 12:06:53 +00:00
Kailasdevdas 3c40b29bff feat: include all images 2026-06-29 16:26:38 +05:30
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
31 changed files with 2971 additions and 250 deletions
@@ -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")
);
+60 -1
View File
@@ -27,6 +27,7 @@ model Doctor {
workingStatus String? workingStatus String?
qualification String? qualification String?
isActive Boolean @default(true) isActive Boolean @default(true)
isFeatured Boolean @default(false)
globalSortOrder Int @default(1000) globalSortOrder Int @default(1000)
specializations DoctorSpecialization[] specializations DoctorSpecialization[]
professionalSummary String? @db.Text professionalSummary String? @db.Text
@@ -45,12 +46,12 @@ model Department {
name String name String
image String? image String?
para1 String? para1 String?
para2 String? para2 String?
para3 String? para3 String?
facilities String? facilities String?
services String? services String?
facilitiesList Facility[]
isActive Boolean @default(true) isActive Boolean @default(true)
sortOrder Int @default(1000) sortOrder Int @default(1000)
@@ -299,6 +300,7 @@ model Seo {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
doctor Doctor? doctor Doctor?
healthPackage HealthPackage? healthPackage HealthPackage?
facility Facility?
seoTitle String? seoTitle String?
@@ -378,3 +380,60 @@ enum AccreditationType {
ACCREDITATION ACCREDITATION
CERTIFICATION 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
}
+4
View File
@@ -19,6 +19,8 @@ import healthCheckRoutes from './routes/healthCheck.route.js';
import homepageBannerRoutes from './routes/homepageBanner.routes.js'; import homepageBannerRoutes from './routes/homepageBanner.routes.js';
import insurancePartnerRoutes from './routes/insurancePartner.routes.js'; import insurancePartnerRoutes from './routes/insurancePartner.routes.js';
import accreditationRoutes from './routes/accreditation.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(); dotenv.config();
@@ -63,6 +65,8 @@ app.use('/api/health-check', healthCheckRoutes);
app.use('/api/homepage-banners', homepageBannerRoutes); app.use('/api/homepage-banners', homepageBannerRoutes);
app.use('/api/insurance-partners', insurancePartnerRoutes); app.use('/api/insurance-partners', insurancePartnerRoutes);
app.use('/api/accreditation', accreditationRoutes); app.use('/api/accreditation', accreditationRoutes);
app.use('/api/facilities', facilityRoutes);
app.use('/api/google-reviews', gReviewRoutes);
const PORT = process.env.PORT || 5008; const PORT = process.env.PORT || 5008;
app.listen(PORT, () => { app.listen(PORT, () => {
@@ -147,7 +147,7 @@ export const getAppointments = async (req, res) => {
const limit = parseInt(req.query.limit) || 10; const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const { date, startDate, endDate, search } = req.query; const { date, startDate, endDate, createdDate, createdStartDate, createdEndDate, search } = req.query;
const where = {}; const where = {};
@@ -161,11 +161,7 @@ export const getAppointments = async (req, res) => {
const end = new Date(date); const end = new Date(date);
end.setHours(23, 59, 59, 999); end.setHours(23, 59, 59, 999);
where.date = { gte: start, lte: end };
where.date = {
gte: start,
lte: end,
};
} }
if (!hasSingleDate && hasRange) { if (!hasSingleDate && hasRange) {
@@ -188,6 +184,33 @@ export const getAppointments = async (req, res) => {
where.date = dateFilter; where.date = dateFilter;
} }
const hasSingleCreatedDate = createdDate && createdDate.trim() !== '';
const hasCreatedRange =
(createdStartDate && createdStartDate.trim() !== '') || (createdEndDate && createdEndDate.trim() !== '');
if (hasSingleCreatedDate) {
const start = new Date(createdDate);
start.setHours(0, 0, 0, 0);
const end = new Date(createdDate);
end.setHours(23, 59, 59, 999);
where.createdAt = { gte: start, lte: end };
}
if (!hasSingleCreatedDate && hasCreatedRange) {
const createdDateFilter = {};
if (createdStartDate && createdStartDate.trim() !== '') {
const start = new Date(createdStartDate);
start.setHours(0, 0, 0, 0);
createdDateFilter.gte = start;
}
if (createdEndDate && createdEndDate.trim() !== '') {
const end = new Date(createdEndDate);
end.setHours(23, 59, 59, 999);
createdDateFilter.lte = end;
}
where.createdAt = createdDateFilter;
}
if (search && search.trim() !== '') { if (search && search.trim() !== '') {
where.OR = [ where.OR = [
{ name: { contains: search, mode: 'insensitive' } }, { name: { contains: search, mode: 'insensitive' } },
+69 -1
View File
@@ -34,6 +34,7 @@ export const getAllDoctors = async (req, res) => {
workingStatus: doc.workingStatus, workingStatus: doc.workingStatus,
qualification: doc.qualification, qualification: doc.qualification,
isActive: doc.isActive, isActive: doc.isActive,
isFeatured: doc.isFeatured,
experience: doc.experience, experience: doc.experience,
professionalSummary: doc.professionalSummary, professionalSummary: doc.professionalSummary,
globalSortOrder: doc.globalSortOrder, globalSortOrder: doc.globalSortOrder,
@@ -129,6 +130,7 @@ export const getDoctorByDoctorId = async (req, res) => {
experience: doctor.experience, experience: doctor.experience,
professionalSummary: doctor.professionalSummary, professionalSummary: doctor.professionalSummary,
isActive: doctor.isActive, isActive: doctor.isActive,
isFeatured: doctor.isFeatured,
seo: { seo: {
seoTitle: doctor.seo?.seoTitle ?? '', seoTitle: doctor.seo?.seoTitle ?? '',
metaDescription: doctor.seo?.metaDescription ?? '', metaDescription: doctor.seo?.metaDescription ?? '',
@@ -240,6 +242,7 @@ export const createDoctor = async (req, res) => {
workingStatus, workingStatus,
qualification, qualification,
isActive, isActive,
isFeatured,
globalSortOrder, globalSortOrder,
departments, departments,
experience, experience,
@@ -297,6 +300,7 @@ export const createDoctor = async (req, res) => {
professionalSummary, professionalSummary,
seoId: seo.id, seoId: seo.id,
isActive: isActive !== undefined ? isActive : true, isActive: isActive !== undefined ? isActive : true,
isFeatured: isFeatured !== undefined ? isFeatured : false,
globalSortOrder: globalSortOrder !== undefined ? Number(globalSortOrder) : 0, globalSortOrder: globalSortOrder !== undefined ? Number(globalSortOrder) : 0,
}, },
}); });
@@ -361,6 +365,7 @@ export const updateDoctor = async (req, res) => {
workingStatus, workingStatus,
qualification, qualification,
isActive, isActive,
isFeatured,
globalSortOrder, globalSortOrder,
departments, departments,
experience, experience,
@@ -397,6 +402,19 @@ export const updateDoctor = async (req, res) => {
message: `Doctor has been ${doctor.isActive ? 'deactivated' : 'activated'} successfully`, 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 = []; const messages = [];
if (!doctorId) messages.push('Doctor ID is required'); if (!doctorId) messages.push('Doctor ID is required');
@@ -423,7 +441,8 @@ export const updateDoctor = async (req, res) => {
image, image,
workingStatus, workingStatus,
qualification, qualification,
isActive, isActive: isActive !== undefined ? isActive : undefined,
isFeatured: isFeatured !== undefined ? isFeatured : undefined,
experience: experience ? Number(experience) : null, experience: experience ? Number(experience) : null,
professionalSummary, professionalSummary,
globalSortOrder: globalSortOrder !== undefined ? Number(globalSortOrder) : undefined, globalSortOrder: globalSortOrder !== undefined ? Number(globalSortOrder) : undefined,
@@ -700,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,424 @@
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,
images: fac.images.map((img) => ({
id: img.id,
url: img.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',
});
}
};
@@ -147,7 +147,9 @@ export const createPackage = async (req, res) => {
image, image,
discountedPrice, discountedPrice,
inclusions, inclusions,
categoryId: Number(categoryId), category: {
connect: { id: Number(categoryId) },
},
isActive: isActive ?? true, isActive: isActive ?? true,
isFeatured: isFeatured ?? false, isFeatured: isFeatured ?? false,
sortOrder: sortOrder ? Number(sortOrder) : 1000, sortOrder: sortOrder ? Number(sortOrder) : 1000,
@@ -184,15 +186,23 @@ export const updatePackage = async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const data = { ...req.body }; const data = { ...req.body };
delete data.id; delete data.id;
delete data.category; delete data.category;
delete data.createdAt; delete data.createdAt;
delete data.updatedAt; delete data.updatedAt;
delete data.seoId; delete data.seoId;
if (data.categoryId) data.categoryId = Number(data.categoryId); if (data.categoryId) {
data.category = { connect: { id: Number(data.categoryId) } };
delete data.categoryId;
}
if (data.sortOrder) data.sortOrder = Number(data.sortOrder); if (data.sortOrder) data.sortOrder = Number(data.sortOrder);
const seoData = data.seo;
delete data.seo;
const existingPackage = await prisma.healthPackage.findUnique({ const existingPackage = await prisma.healthPackage.findUnique({
where: { id: Number(id) }, where: { id: Number(id) },
select: { slug: true }, select: { slug: true },
@@ -205,29 +215,29 @@ export const updatePackage = async (req, res) => {
data: { data: {
...data, ...data,
seo: data.seo seo: seoData
? { ? {
upsert: { upsert: {
create: { create: {
seoTitle: data.seo.seoTitle, seoTitle: seoData.seoTitle,
metaDescription: data.seo.metaDescription, metaDescription: seoData.metaDescription,
focusKeyphrase: data.seo.focusKeyphrase, focusKeyphrase: seoData.focusKeyphrase,
slug: seoSlug, slug: seoSlug,
tags: data.seo.tags || [], tags: seoData.tags || [],
ogTitle: data.seo.ogTitle, ogTitle: seoData.ogTitle,
ogDescription: data.seo.ogDescription, ogDescription: seoData.ogDescription,
ogImage: data.seo.ogImage, ogImage: seoData.ogImage,
}, },
update: { update: {
seoTitle: data.seo.seoTitle, seoTitle: seoData.seoTitle,
metaDescription: data.seo.metaDescription, metaDescription: seoData.metaDescription,
focusKeyphrase: data.seo.focusKeyphrase, focusKeyphrase: seoData.focusKeyphrase,
slug: seoSlug, slug: seoSlug,
tags: data.seo.tags || [], tags: seoData.tags || [],
ogTitle: data.seo.ogTitle, ogTitle: seoData.ogTitle,
ogDescription: data.seo.ogDescription, ogDescription: seoData.ogDescription,
ogImage: data.seo.ogImage, ogImage: seoData.ogImage,
}, },
}, },
} }
@@ -431,7 +441,17 @@ export const getPackageBySlug = async (req, res) => {
export const getAllInquiries = async (req, res) => { export const getAllInquiries = async (req, res) => {
try { try {
const { page = 1, limit = 10, filterDate, startDate, endDate } = req.query; const {
page = 1,
limit = 10,
filterDate,
startDate,
endDate,
createdDate,
createdStartDate,
createdEndDate,
search,
} = req.query;
const queryPage = parseInt(page); const queryPage = parseInt(page);
const queryLimit = parseInt(limit); const queryLimit = parseInt(limit);
@@ -439,6 +459,15 @@ export const getAllInquiries = async (req, res) => {
let where = {}; let where = {};
if (search && search.trim() !== '') {
where.OR = [
{ fullName: { contains: search, mode: 'insensitive' } },
{ mobileNumber: { contains: search } },
{ email: { contains: search, mode: 'insensitive' } },
{ healthPackage: { name: { contains: search, mode: 'insensitive' } } },
];
}
if (filterDate) { if (filterDate) {
where.preferredDate = { where.preferredDate = {
gte: new Date(`${filterDate}T00:00:00.000Z`), gte: new Date(`${filterDate}T00:00:00.000Z`),
@@ -454,6 +483,21 @@ export const getAllInquiries = async (req, res) => {
} }
} }
if (createdDate) {
where.createdAt = {
gte: new Date(`${createdDate}T00:00:00.000Z`),
lte: new Date(`${createdDate}T23:59:59.999Z`),
};
} else if (createdStartDate || createdEndDate) {
where.createdAt = {};
if (createdStartDate) {
where.createdAt.gte = new Date(`${createdStartDate}T00:00:00.000Z`);
}
if (createdEndDate) {
where.createdAt.lte = new Date(`${createdEndDate}T23:59:59.999Z`);
}
}
const [total, inquiries] = await prisma.$transaction([ const [total, inquiries] = await prisma.$transaction([
prisma.healthPackageInquiry.count({ where }), prisma.healthPackageInquiry.count({ where }),
prisma.healthPackageInquiry.findMany({ prisma.healthPackageInquiry.findMany({
@@ -486,3 +530,33 @@ export const getAllInquiries = async (req, res) => {
return res.status(500).json({ success: false, message: 'Failed to fetch inquiries' }); 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',
});
}
};
+2
View File
@@ -8,6 +8,7 @@ import {
getDoctorTimingById, getDoctorTimingById,
getDoctorByDoctorId, getDoctorByDoctorId,
getDoctorsByDepartmentId, getDoctorsByDepartmentId,
getFeaturedDoctors,
} from '../controllers/doctor.controller.js'; } from '../controllers/doctor.controller.js';
import jwtAuthMiddleware from '../middleware/auth.js'; import jwtAuthMiddleware from '../middleware/auth.js';
@@ -18,6 +19,7 @@ router.get('/getAll', getAllDoctors);
router.get('/search', getDoctorsByDepartmentId); router.get('/search', getDoctorsByDepartmentId);
router.get('/getTimings', getDoctorTimings); router.get('/getTimings', getDoctorTimings);
router.get('/getTimings/:doctorId', getDoctorTimingById); router.get('/getTimings/:doctorId', getDoctorTimingById);
router.get('/featured', getFeaturedDoctors);
router.get('/:doctorId', getDoctorByDoctorId); router.get('/:doctorId', getDoctorByDoctorId);
router.post('/', jwtAuthMiddleware, createDoctor); 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, createPackage,
updatePackage, updatePackage,
deletePackage, deletePackage,
getFeaturedPackages,
// Inquiries // Inquiries
createPackageInquiry, createPackageInquiry,
@@ -26,6 +27,7 @@ router.get('/packages', getAllPackages);
router.get('/packages/:slug', getPackageBySlug); router.get('/packages/:slug', getPackageBySlug);
router.get('/categories', getAllCategories); router.get('/categories', getAllCategories);
router.post('/inquiry', createPackageInquiry); router.post('/inquiry', createPackageInquiry);
router.get('/featured', getFeaturedPackages);
router.get('/inquiries', jwtAuthMiddleware, getAllInquiries); router.get('/inquiries', jwtAuthMiddleware, getAllInquiries);
router.post('/', jwtAuthMiddleware, createPackage); router.post('/', jwtAuthMiddleware, createPackage);
+4
View File
@@ -27,6 +27,8 @@ import HealthPackagePage from './pages/HealthPackagePage';
import HomepageBanner from './pages/HomepageBannerPage'; import HomepageBanner from './pages/HomepageBannerPage';
import InsurancePartnerPage from './pages/InsurancePartner'; import InsurancePartnerPage from './pages/InsurancePartner';
import AccreditationPage from './pages/Accreditation'; import AccreditationPage from './pages/Accreditation';
import FacilityPage from './pages/FacilityPage';
import GoogleReviewPage from './pages/GoogleReviewPage';
export default function App() { export default function App() {
return ( return (
@@ -59,6 +61,8 @@ export default function App() {
<Route path="/homepage-banner" element={<HomepageBanner />} /> <Route path="/homepage-banner" element={<HomepageBanner />} />
<Route path="/insurance-partner" element={<InsurancePartnerPage />} /> <Route path="/insurance-partner" element={<InsurancePartnerPage />} />
<Route path="/accreditation" element={<AccreditationPage />} /> <Route path="/accreditation" element={<AccreditationPage />} />
<Route path="/facility" element={<FacilityPage />} />
<Route path="/reviews" element={<GoogleReviewPage />} />
</Route> </Route>
</Route> </Route>
+6
View File
@@ -6,6 +6,9 @@ export const getAppointmentsApi = async (
date = '', date = '',
startDate = '', startDate = '',
endDate = '', endDate = '',
createdDate = '',
createdStartDate = '',
createdEndDate = '',
search = '' search = ''
) => { ) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -14,6 +17,9 @@ export const getAppointmentsApi = async (
...(date && { date }), ...(date && { date }),
...(startDate && { startDate }), ...(startDate && { startDate }),
...(endDate && { endDate }), ...(endDate && { endDate }),
...(createdDate && { createdDate }),
...(createdStartDate && { createdStartDate }),
...(createdEndDate && { createdEndDate }),
...(search && { search }), ...(search && { search }),
}); });
const res = await apiClient.get(`/appointments/getall?${params}`); const res = await apiClient.get(`/appointments/getall?${params}`);
+2 -1
View File
@@ -9,6 +9,7 @@ export interface Doctor {
workingStatus?: string; workingStatus?: string;
qualification?: string; qualification?: string;
isActive: boolean; isActive: boolean;
isFeatured: boolean;
globalSortOrder: number; globalSortOrder: number;
departments?: { departments?: {
@@ -53,7 +54,7 @@ export const createDoctorApi = async (data: Doctor) => {
export const updateDoctorApi = async ( export const updateDoctorApi = async (
doctorId: string, doctorId: string,
data: Partial<Doctor>, data: Partial<Doctor>,
action: 'toggleStatus' | 'updateDetails' = 'updateDetails' action: 'toggleStatus' | 'toggleFeatured' | 'updateDetails' = 'updateDetails'
) => { ) => {
try { try {
const res = await apiClient.patch(`/doctors/${doctorId}/${action}`, data); 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;
}
};
+15 -1
View File
@@ -140,7 +140,17 @@ export const deleteCategoryApi = async (id: number) => {
} }
}; };
export const getAllInquiriesApi = async (page = 1, limit = 10, filterDate = '', startDate = '', endDate = '') => { export const getAllInquiriesApi = async (
page = 1,
limit = 10,
filterDate = '',
startDate = '',
endDate = '',
createdDate = '',
createdStartDate = '',
createdEndDate = '',
search = ''
) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: page.toString(), page: page.toString(),
limit: limit.toString(), limit: limit.toString(),
@@ -149,6 +159,10 @@ export const getAllInquiriesApi = async (page = 1, limit = 10, filterDate = '',
if (filterDate) params.append('filterDate', filterDate); if (filterDate) params.append('filterDate', filterDate);
if (startDate) params.append('startDate', startDate); if (startDate) params.append('startDate', startDate);
if (endDate) params.append('endDate', endDate); if (endDate) params.append('endDate', endDate);
if (createdDate) params.append('createdDate', createdDate);
if (createdStartDate) params.append('createdStartDate', createdStartDate);
if (createdEndDate) params.append('createdEndDate', createdEndDate);
if (search) params.append('search', search);
const res = await apiClient.get(`/health-check/inquiries?${params.toString()}`); const res = await apiClient.get(`/health-check/inquiries?${params.toString()}`);
return res.data; return res.data;
@@ -16,7 +16,9 @@ interface BytescaleUploaderProps {
| '/doctor-og' | '/doctor-og'
| '/homepage-banners' | '/homepage-banners'
| '/insurance-partners' | '/insurance-partners'
| '/accreditations'; | '/accreditations'
| '/facilities'
| '/reviews';
} }
export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUploaderProps) { export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUploaderProps) {
@@ -0,0 +1,375 @@
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">
Facility Image{' '}
<span className="text-muted-foreground font-normal">
(Recommended: {idx === 0 ? '800 × 530 px' : '350 × 230 px'})
</span>
</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">SEO Settings</p>
<div className="space-y-2">
<Label className="text-sm font-semibold">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">Meta 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 Keyphrase</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 Slug</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">Tags / Keywords</Label>
<div className="flex flex-wrap gap-2 border rounded-md p-3 min-h-[48px] bg-background">
{form.tags?.map((tag: string, 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">
Save Changes{' '}
</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,11 +120,11 @@ export default function HealthPackageModal({
/> />
</div> </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 className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div> <div>
<p className="font-semibold">Active Visibility</p> <p className="font-semibold">Active</p>
<p className="text-sm text-muted-foreground">Show publicly</p>
<p className="text-sm text-muted-foreground">Show this package publicly</p>
</div> </div>
<Switch <Switch
@@ -138,6 +138,24 @@ export default function HealthPackageModal({
/> />
</div> </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="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="font-semibold">Package Name</Label> <Label className="font-semibold">Package Name</Label>
@@ -7,16 +7,24 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Loader2, RefreshCw, ChevronLeft, ChevronRight } from 'lucide-react'; import { Loader2, RefreshCw, ChevronLeft, ChevronRight, CalendarDays } from 'lucide-react';
export default function PackageInquiriesTab() { export default function PackageInquiriesTab() {
const [inquiries, setInquiries] = useState<HealthInquiry[]>([]); const [inquiries, setInquiries] = useState<HealthInquiry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState('');
const [dateMode, setDateMode] = useState<'preferred' | 'booked'>('preferred');
const [filterDate, setFilterDate] = useState(''); const [filterDate, setFilterDate] = useState('');
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState('');
const [createdDate, setCreatedDate] = useState('');
const [createdStartDate, setCreatedStartDate] = useState('');
const [createdEndDate, setCreatedEndDate] = useState('');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
const [totalItems, setTotalItems] = useState(0); const [totalItems, setTotalItems] = useState(0);
@@ -25,7 +33,17 @@ export default function PackageInquiriesTab() {
const fetchInquiries = useCallback(async () => { const fetchInquiries = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await getAllInquiriesApi(currentPage, itemsPerPage, filterDate, startDate, endDate); const res = await getAllInquiriesApi(
currentPage,
itemsPerPage,
filterDate,
startDate,
endDate,
createdDate,
createdStartDate,
createdEndDate,
searchText
);
setInquiries(res.data || []); setInquiries(res.data || []);
setTotalItems(res.pagination?.total || 0); setTotalItems(res.pagination?.total || 0);
setTotalPages(res.pagination?.totalPages || 1); setTotalPages(res.pagination?.totalPages || 1);
@@ -34,144 +52,216 @@ export default function PackageInquiriesTab() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [currentPage, itemsPerPage, filterDate, startDate, endDate]); }, [
currentPage,
itemsPerPage,
filterDate,
startDate,
endDate,
createdDate,
createdStartDate,
createdEndDate,
searchText,
]);
useEffect(() => { useEffect(() => {
fetchInquiries(); fetchInquiries();
}, [fetchInquiries]); }, [fetchInquiries]);
const handleFilterChange = (setter: React.Dispatch<React.SetStateAction<string>>, value: string) => { const handleModeChange = (newMode: 'preferred' | 'booked') => {
setter(value); setDateMode(newMode);
setFilterDate('');
setStartDate('');
setEndDate('');
setCreatedDate('');
setCreatedStartDate('');
setCreatedEndDate('');
setCurrentPage(1); setCurrentPage(1);
}; };
useEffect(() => {
setCurrentPage(1);
}, [searchText, filterDate, startDate, endDate, createdDate, createdStartDate, createdEndDate, itemsPerPage]);
const indexOfFirstItem = (currentPage - 1) * itemsPerPage; const indexOfFirstItem = (currentPage - 1) * itemsPerPage;
const indexOfLastItem = Math.min(currentPage * itemsPerPage, totalItems); const indexOfLastItem = Math.min(currentPage * itemsPerPage, totalItems);
return ( return (
<Card> <Card className="shadow-sm">
<CardHeader className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4"> <CardHeader className="flex flex-col gap-4 border-b pb-4 w-full">
<CardTitle className="text-xl">Package Inquiries</CardTitle> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 w-full">
<CardTitle className="text-xl flex items-center gap-2">
<div className="flex flex-wrap items-end gap-3"> <span>Package Inquiries</span>
<div className="flex flex-col gap-1"> </CardTitle>
<label className="text-xs font-medium text-muted-foreground">Specific Date</label> <Button variant="outline" size="sm" onClick={fetchInquiries} disabled={loading} className="text-sm">
<Input
type="date"
value={filterDate}
onChange={(e) => handleFilterChange(setFilterDate, e.target.value)}
className="w-[140px] text-sm"
disabled={!!startDate || !!endDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">From</label>
<Input
type="date"
value={startDate}
onChange={(e) => handleFilterChange(setStartDate, e.target.value)}
className="w-[140px] text-sm"
disabled={!!filterDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">To</label>
<Input
type="date"
value={endDate}
onChange={(e) => handleFilterChange(setEndDate, e.target.value)}
className="w-[140px] text-sm"
disabled={!!filterDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Rows</label>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm focus:ring-2 focus:ring-primary"
>
<option value={5}>5 / page</option>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
</select>
</div>
<Button variant="outline" onClick={fetchInquiries} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh Refresh
</Button> </Button>
</div> </div>
<div className="grid grid-cols-1 xl:grid-cols-4 gap-4 bg-muted/20 p-4 rounded-lg border w-full">
<div className="flex flex-col gap-1.5 w-full">
<label className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Search Inquiry</label>
<Input
placeholder="Search name, phone, email, or package name..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="text-base h-10 bg-background w-full"
/>
</div>
<div className="flex flex-col gap-1.5 w-full">
<label className="text-xs font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-1">
<CalendarDays className="h-3.5 w-3.5 text-primary" /> Filter Date By
</label>
<select
value={dateMode}
onChange={(e) => handleModeChange(e.target.value as 'preferred' | 'booked')}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-medium focus:ring-2 focus:ring-primary focus:outline-none"
>
<option value="preferred">🗓 Preferred Date</option>
<option value="booked"> Booked Date (Created)</option>
</select>
</div>
<div className="flex items-end gap-2 p-1.5 bg-background rounded-md border border-dashed flex-wrap sm:flex-nowrap w-full">
<div className="flex flex-col gap-1 flex-1 min-w-[80px]">
<span className="text-[10px] font-bold text-muted-foreground uppercase">Specific Day</span>
<Input
type="date"
value={dateMode === 'preferred' ? filterDate : createdDate}
onChange={(e) =>
dateMode === 'preferred' ? setFilterDate(e.target.value) : setCreatedDate(e.target.value)
}
className="text-sm h-8"
disabled={dateMode === 'preferred' ? !!startDate || !!endDate : !!createdStartDate || !!createdEndDate}
/>
</div>
<div className="flex flex-col gap-1 flex-1 min-w-[80px]">
<span className="text-[10px] font-bold text-muted-foreground uppercase">From Range</span>
<Input
type="date"
value={dateMode === 'preferred' ? startDate : createdStartDate}
onChange={(e) =>
dateMode === 'preferred' ? setStartDate(e.target.value) : setCreatedStartDate(e.target.value)
}
className="text-sm h-8"
disabled={dateMode === 'preferred' ? !!filterDate : !!createdDate}
/>
</div>
<div className="flex flex-col gap-1 flex-1 min-w-[80px]">
<span className="text-[10px] font-bold text-muted-foreground uppercase">To Range</span>
<Input
type="date"
value={dateMode === 'preferred' ? endDate : createdEndDate}
onChange={(e) =>
dateMode === 'preferred' ? setEndDate(e.target.value) : setCreatedEndDate(e.target.value)
}
className="text-sm h-8"
disabled={dateMode === 'preferred' ? !!filterDate : !!createdDate}
/>
</div>
</div>
<div className="flex flex-col gap-1.5 w-full">
<label className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Page Sizing</label>
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:outline-none"
>
<option value={5}>5 Rows</option>
<option value={10}>10 Rows</option>
<option value={20}>20 Rows</option>
<option value={50}>50 Rows</option>
</select>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent className="p-0 sm:p-6 sm:pt-0"> <CardContent className="p-0 sm:p-6 sm:pt-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative"> <div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[1000px] table-fixed border-separate border-spacing-0"> <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"> <TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow> <TableRow>
<TableHead className="w-[150px] font-bold bg-background">Requested Date</TableHead> <TableHead className="w-[145px] font-bold bg-background text-sm">Preferred Date</TableHead>
<TableHead className="w-[220px] font-bold bg-background">Patient Details</TableHead> <TableHead className="w-[145px] font-bold bg-background text-sm">Booked Date</TableHead>
<TableHead className="w-[250px] font-bold bg-background">Requested Package</TableHead> <TableHead className="w-[220px] font-bold bg-background text-sm">Patient Details</TableHead>
<TableHead className="w-[120px] font-bold bg-background">Age/Gender</TableHead> <TableHead className="w-[240px] font-bold bg-background text-sm">Requested Package</TableHead>
<TableHead className="w-[250px] font-bold bg-background">Message</TableHead> <TableHead className="w-[120px] font-bold bg-background text-sm">Demographics</TableHead>
<TableHead className="w-[230px] font-bold bg-background text-sm">Message</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center py-10"> <TableCell colSpan={6} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" /> <Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : inquiries.length === 0 ? ( ) : inquiries.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-10"> <TableCell colSpan={6} className="text-center text-muted-foreground py-10 text-base">
No inquiries found for the selected criteria No package inquiries matched your active search filters.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
inquiries.map((inq) => ( inquiries.map((inq) => (
<TableRow key={inq.id} className="hover:bg-muted/50"> <TableRow key={inq.id} className="hover:bg-muted/50 transition-colors">
<TableCell> <TableCell>
<div className="font-semibold text-primary"> {inq.preferredDate ? (
{new Date(inq.preferredDate).toLocaleDateString()} <div
className={`text-sm font-semibold rounded px-1.5 py-0.5 inline-block ${dateMode === 'preferred' ? 'bg-primary/10 text-primary' : 'bg-muted'}`}
>
{new Date(inq.preferredDate).toLocaleDateString('en-GB')}
</div> </div>
<div className="text-[11px] text-muted-foreground mt-1"> ) : (
Submitted: {new Date(inq.createdAt).toLocaleDateString()} <span className="text-xs text-muted-foreground italic">Not specified</span>
)}
</TableCell>
<TableCell>
<div
className={`text-sm rounded px-1.5 py-0.5 inline-block ${dateMode === 'booked' ? 'bg-sky-500/10 text-sky-600 font-semibold' : 'text-muted-foreground'}`}
>
{new Date(inq.createdAt).toLocaleDateString('en-GB')}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="font-semibold text-base">{inq.fullName}</div> <div className="font-semibold text-base text-gray-900 truncate">{inq.fullName}</div>
<div className="text-sm">{inq.mobileNumber}</div> <div className="text-sm text-gray-700 mt-0.5">{inq.mobileNumber}</div>
<div className="text-xs text-muted-foreground">{inq.email || '-'}</div> {inq.email && <div className="text-xs text-muted-foreground truncate">{inq.email}</div>}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="font-semibold text-sm truncate">{inq.healthPackage?.name || 'N/A'}</div> <div className="font-semibold text-sm line-clamp-2 text-gray-800" title={inq.healthPackage?.name}>
{inq.healthPackage?.name || 'N/A'}
</div>
{inq.healthPackage?.category?.name && (
<Badge variant="secondary" className="text-[10px] font-normal px-2 py-0 mt-1">
{inq.healthPackage.category.name}
</Badge>
)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="font-medium"> <div className="font-medium text-sm text-gray-800">{inq.age ? `${inq.age} yrs` : '—'}</div>
{inq.age} yrs / {inq.gender} <div className="text-xs text-muted-foreground capitalize mt-0.5">
{inq.gender || 'unspecified'}
</div> </div>
</TableCell> </TableCell>
<TableCell>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="text-sm italic line-clamp-3 text-muted-foreground whitespace-pre-wrap cursor-pointer"> <div className="text-sm italic line-clamp-3 text-muted-foreground whitespace-pre-wrap cursor-pointer leading-relaxed hover:text-foreground transition-colors">
{inq.message || 'No message provided.'} {inq.message || 'No message provided.'}
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-md whitespace-pre-wrap p-3 bg-popover text-popover-foreground border shadow-md rounded-md">
<TooltipContent className="max-w-md whitespace-pre-wrap">
{inq.message || 'No message provided.'} {inq.message || 'No message provided.'}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
@@ -187,10 +277,10 @@ export default function PackageInquiriesTab() {
<span className="font-semibold">{totalItems}</span> inquiries <span className="font-semibold">{totalItems}</span> inquiries
</div> </div>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="text-sm font-semibold"> <div className="text-sm font-medium text-gray-700">
Page {currentPage} of {totalPages || 1} Page {currentPage} of {totalPages || 1}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-1">
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
@@ -65,6 +65,8 @@ export default function Sidebar() {
path: '/insurance-partner', path: '/insurance-partner',
}, },
{ name: 'Accreditation', path: '/accreditation' }, { name: 'Accreditation', path: '/accreditation' },
{ name: 'Facility', path: '/facility' },
{ name: 'Google Review', path: '/reviews' },
], ],
}, },
]; ];
+173 -106
View File
@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Loader2, Trash, RefreshCw, Download, ChevronLeft, ChevronRight, Eye } from 'lucide-react'; import { Loader2, Trash, RefreshCw, Download, ChevronLeft, ChevronRight, Eye, CalendarDays } from 'lucide-react';
export default function AppointmentPage() { export default function AppointmentPage() {
const [appointments, setAppointments] = useState<any[]>([]); const [appointments, setAppointments] = useState<any[]>([]);
@@ -18,9 +18,17 @@ export default function AppointmentPage() {
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [filterDoctor, setFilterDoctor] = useState(''); const [filterDoctor, setFilterDoctor] = useState('');
const [dateMode, setDateMode] = useState<'scheduled' | 'booked'>('scheduled');
const [filterDate, setFilterDate] = useState(''); const [filterDate, setFilterDate] = useState('');
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState('');
const [createdDate, setCreatedDate] = useState('');
const [createdStartDate, setCreatedStartDate] = useState('');
const [createdEndDate, setCreatedEndDate] = useState('');
const [viewOpen, setViewOpen] = useState(false); const [viewOpen, setViewOpen] = useState(false);
const [viewData, setViewData] = useState<any>(null); const [viewData, setViewData] = useState<any>(null);
@@ -32,7 +40,17 @@ export default function AppointmentPage() {
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await getAppointmentsApi(currentPage, itemsPerPage, filterDate, startDate, endDate, searchText); const res = await getAppointmentsApi(
currentPage,
itemsPerPage,
filterDate,
startDate,
endDate,
createdDate,
createdStartDate,
createdEndDate,
searchText
);
setAppointments(res?.data || []); setAppointments(res?.data || []);
setTotalPages(res?.pagination?.totalPages || 1); setTotalPages(res?.pagination?.totalPages || 1);
setTotalItems(res?.pagination?.total || 0); setTotalItems(res?.pagination?.total || 0);
@@ -41,7 +59,17 @@ export default function AppointmentPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [currentPage, itemsPerPage, filterDate, startDate, endDate, searchText]); }, [
currentPage,
itemsPerPage,
filterDate,
startDate,
endDate,
createdDate,
createdStartDate,
createdEndDate,
searchText,
]);
useEffect(() => { useEffect(() => {
fetchAll(); fetchAll();
@@ -53,9 +81,20 @@ export default function AppointmentPage() {
return matchesDoctor; return matchesDoctor;
}); });
const handleModeChange = (newMode: 'scheduled' | 'booked') => {
setDateMode(newMode);
setFilterDate('');
setStartDate('');
setEndDate('');
setCreatedDate('');
setCreatedStartDate('');
setCreatedEndDate('');
setCurrentPage(1);
};
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
}, [searchText, filterDoctor, filterDate]); }, [searchText, filterDoctor, filterDate, startDate, endDate, createdDate, createdStartDate, createdEndDate]);
const indexOfFirstItem = (currentPage - 1) * itemsPerPage; const indexOfFirstItem = (currentPage - 1) * itemsPerPage;
@@ -78,7 +117,8 @@ export default function AppointmentPage() {
Email: item.email, Email: item.email,
Doctor: item.doctor?.name, Doctor: item.doctor?.name,
Department: item.department?.name, Department: item.department?.name,
Date: new Date(item.date).toLocaleDateString(), ScheduledDate: new Date(item.date).toLocaleDateString(),
BookedDate: new Date(item.createdAt).toLocaleDateString(),
Message: item.message, Message: item.message,
})); }));
exportToExcel(exportData, 'appointments'); exportToExcel(exportData, 'appointments');
@@ -87,85 +127,12 @@ export default function AppointmentPage() {
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4"> <div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<h1 className="text-3xl font-bold">Appointments</h1> <h1 className="text-3xl font-bold tracking-tight">Appointments</h1>
<div className="flex items-center gap-3">
<div className="flex flex-wrap gap-4 items-end">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Search</label>
<Input
placeholder="Search name / phone..."
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
setCurrentPage(1);
}}
className="w-[220px] text-base"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Date</label>
<Input
type="date"
value={filterDate}
onChange={(e) => {
setFilterDate(e.target.value);
setCurrentPage(1);
}}
className="w-[160px] text-base"
disabled={!!startDate || !!endDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">From</label>
<Input
type="date"
value={startDate}
onChange={(e) => {
setStartDate(e.target.value);
setCurrentPage(1);
}}
className="w-[160px] text-base"
disabled={!!filterDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">To</label>
<Input
type="date"
value={endDate}
onChange={(e) => {
setEndDate(e.target.value);
setCurrentPage(1);
}}
className="w-[160px] text-base"
disabled={!!filterDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Rows</label>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm focus:ring-2 focus:ring-primary"
>
<option value={5}>5 / page</option>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
</select>
</div>
<Button variant="outline" onClick={fetchAll} disabled={loading} className="text-base"> <Button variant="outline" onClick={fetchAll} disabled={loading} className="text-base">
<RefreshCw className="mr-2 h-5 w-5" /> <RefreshCw className="mr-2 h-5 w-5" />
Refresh Refresh
</Button> </Button>
<Button onClick={handleExport} className="text-base"> <Button onClick={handleExport} className="text-base">
<Download className="mr-2 h-5 w-5" /> <Download className="mr-2 h-5 w-5" />
Export Export
@@ -173,41 +140,128 @@ export default function AppointmentPage() {
</div> </div>
</div> </div>
<Card className="shadow-sm">
<CardContent className="p-4 flex flex-wrap xl:flex-nowrap items-end gap-4">
<div className="flex flex-col gap-1.5 flex-1 min-w-[240px]">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Search Target
</label>
<Input
placeholder="Search patient name, phone, or email..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="text-base h-10"
/>
</div>
<div className="flex flex-col gap-1.5 w-[180px]">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1">
<CalendarDays className="h-3.5 w-3.5 text-primary" /> Filter Date By
</label>
<select
value={dateMode}
onChange={(e) => handleModeChange(e.target.value as 'scheduled' | 'booked')}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-medium focus:ring-2 focus:ring-primary focus:outline-none"
>
<option value="scheduled">🗓 Scheduled Date</option>
<option value="booked"> Booked (Created At)</option>
</select>
</div>
<div className="flex items-end gap-2 p-2 bg-muted/40 rounded-md border border-dashed flex-wrap sm:flex-nowrap flex-1 min-w-[320px]">
<div className="flex flex-col gap-1 flex-1 min-w-[110px]">
<span className="text-[10px] font-bold text-muted-foreground uppercase">Specific Day</span>
<Input
type="date"
value={dateMode === 'scheduled' ? filterDate : createdDate}
onChange={(e) =>
dateMode === 'scheduled' ? setFilterDate(e.target.value) : setCreatedDate(e.target.value)
}
className="text-sm h-8 bg-background"
disabled={dateMode === 'scheduled' ? !!startDate || !!endDate : !!createdStartDate || !!createdEndDate}
/>
</div>
<div className="flex flex-col gap-1 flex-1 min-w-[110px]">
<span className="text-[10px] font-bold text-muted-foreground uppercase">From Range</span>
<Input
type="date"
value={dateMode === 'scheduled' ? startDate : createdStartDate}
onChange={(e) =>
dateMode === 'scheduled' ? setStartDate(e.target.value) : setCreatedStartDate(e.target.value)
}
className="text-sm h-8 bg-background"
disabled={dateMode === 'scheduled' ? !!filterDate : !!createdDate}
/>
</div>
<div className="flex flex-col gap-1 flex-1 min-w-[110px]">
<span className="text-[10px] font-bold text-muted-foreground uppercase">To Range</span>
<Input
type="date"
value={dateMode === 'scheduled' ? endDate : createdEndDate}
onChange={(e) =>
dateMode === 'scheduled' ? setEndDate(e.target.value) : setCreatedEndDate(e.target.value)
}
className="text-sm h-8 bg-background"
disabled={dateMode === 'scheduled' ? !!filterDate : !!createdDate}
/>
</div>
</div>
<div className="flex flex-col gap-1.5 w-[95px]">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Page Limit</label>
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:outline-none"
>
<option value={5}>5 Rows</option>
<option value={10}>10 Rows</option>
<option value={20}>20 Rows</option>
</select>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader className="px-6 py-4 border-b">
<CardTitle className="text-xl">Appointment List</CardTitle> <CardTitle className="text-xl flex items-center gap-2">
<span>Appointment Registrations</span>
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-0 sm:p-6 space-y-4"> <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"> <div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[1000px] table-fixed border-separate border-spacing-0"> <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"> <TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow> <TableRow>
<TableHead className="w-[60px] bg-background font-bold text-sm">ID</TableHead> <TableHead className="w-[60px] bg-background font-bold text-sm">ID</TableHead>
<TableHead className="w-[200px] bg-background font-bold text-sm">Patient</TableHead> <TableHead className="w-[200px] bg-background font-bold text-sm">Patient</TableHead>
<TableHead className="w-[180px] bg-background font-bold text-sm">Doctor</TableHead> <TableHead className="w-[180px] bg-background font-bold text-sm">Doctor</TableHead>
<TableHead className="w-[150px] bg-background font-bold text-sm">Date</TableHead> <TableHead className="w-[140px] bg-background font-bold text-sm">Scheduled Date</TableHead>
<TableHead className="w-[250px] bg-background font-bold text-sm">Message</TableHead> <TableHead className="w-[140px] bg-background font-bold text-sm">Booked Date</TableHead>
<TableHead className="w-[120px] bg-background font-bold text-right text-sm">Actions</TableHead> <TableHead className="w-[200px] bg-background font-bold text-sm">Message</TableHead>
<TableHead className="w-[100px] bg-background font-bold text-right text-sm">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <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" /> <Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : filteredAppointments.length === 0 ? ( ) : filteredAppointments.length === 0 ? (
<TableRow> <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 appointments found No appointments found matching your selected criteria.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredAppointments.map((item) => ( filteredAppointments.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50"> <TableRow key={item.id} className="hover:bg-muted/50 transition-colors">
<TableCell className="font-mono text-xs">{item.id}</TableCell> <TableCell className="font-mono text-xs">{item.id}</TableCell>
<TableCell> <TableCell>
<div className="font-semibold text-base truncate">{item.name}</div> <div className="font-semibold text-base truncate">{item.name}</div>
@@ -218,13 +272,24 @@ export default function AppointmentPage() {
<div className="text-[10px] text-muted-foreground truncate">{item.department?.name}</div> <div className="text-[10px] text-muted-foreground truncate">{item.department?.name}</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="text-sm">{new Date(item.date).toLocaleDateString()}</div> <div
className={`text-sm font-semibold rounded px-1.5 py-0.5 inline-block ${dateMode === 'scheduled' ? 'bg-primary/10 text-primary' : 'bg-muted'}`}
>
{new Date(item.date).toLocaleDateString()}
</div>
</TableCell>
<TableCell>
<div
className={`text-sm rounded px-1.5 py-0.5 inline-block ${dateMode === 'booked' ? 'bg-sky-500/10 text-sky-600 font-semibold' : 'text-muted-foreground'}`}
>
{new Date(item.createdAt).toLocaleDateString()}
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="text-sm line-clamp-2 text-muted-foreground italic">{item.message || '-'}</div> <div className="text-sm line-clamp-2 text-muted-foreground italic">{item.message || '-'}</div>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-1">
<Button size="icon" variant="ghost" className="h-9 w-9" onClick={() => openView(item)}> <Button size="icon" variant="ghost" className="h-9 w-9" onClick={() => openView(item)}>
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
@@ -246,34 +311,34 @@ export default function AppointmentPage() {
</div> </div>
{!loading && totalItems > 0 && ( {!loading && totalItems > 0 && (
<div className="flex items-center justify-between px-2 py-6 border-t"> <div className="flex items-center justify-between px-2 py-4 border-t">
<div className="text-base text-muted-foreground"> <div className="text-sm text-muted-foreground">
Showing <span className="font-semibold">{indexOfFirstItem + 1}</span> to{' '} Showing <span className="font-semibold">{indexOfFirstItem + 1}</span> to{' '}
<span className="font-semibold">{Math.min(currentPage * itemsPerPage, totalItems)}</span> of{' '} <span className="font-semibold">{Math.min(currentPage * itemsPerPage, totalItems)}</span> of{' '}
<span className="font-semibold">{totalItems}</span> <span className="font-semibold">{totalItems}</span> entries
</div> </div>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="text-base font-semibold"> <div className="text-sm font-medium">
Page {currentPage} of {totalPages} Page {currentPage} of {totalPages}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-1">
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="h-10 w-10" className="h-9 w-9"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))} onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
> >
<ChevronLeft className="h-5 w-5" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="h-10 w-10" className="h-9 w-9"
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))} onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages || totalPages === 0} disabled={currentPage === totalPages || totalPages === 0}
> >
<ChevronRight className="h-5 w-5" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
@@ -298,10 +363,12 @@ export default function AppointmentPage() {
<p className="text-sm">{viewData.email || 'No email provided'}</p> <p className="text-sm">{viewData.email || 'No email provided'}</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase font-bold text-muted-foreground">Appointment Date</p> <p className="text-xs uppercase font-bold text-muted-foreground">Key Datestamps</p>
<p className="text-base font-semibold">{new Date(viewData.date).toLocaleDateString()}</p> <p className="text-base font-semibold text-primary">
<p className="text-[10px] text-muted-foreground"> Scheduled: {new Date(viewData.date).toLocaleDateString()}
Booked on: {new Date(viewData.createdAt).toLocaleString()} </p>
<p className="text-xs text-muted-foreground">
Record Created: {new Date(viewData.createdAt).toLocaleString()}
</p> </p>
</div> </div>
</div> </div>
+44 -4
View File
@@ -51,6 +51,7 @@ export default function DoctorPage() {
workingStatus: '', workingStatus: '',
qualification: '', qualification: '',
isActive: true, isActive: true,
isFeatured: false,
globalSortOrder: 0, globalSortOrder: 0,
departments: [], departments: [],
professionalSummary: '', 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) { function handleDepartmentToggle(depId: string) {
const exists = form.departments.find((d: any) => d.departmentId === depId); const exists = form.departments.find((d: any) => d.departmentId === depId);
if (exists) { if (exists) {
@@ -201,6 +218,7 @@ export default function DoctorPage() {
experience: '', experience: '',
professionalSummary: '', professionalSummary: '',
isActive: true, isActive: true,
isFeatured: false,
globalSortOrder: 0, globalSortOrder: 0,
specializations: [ specializations: [
{ {
@@ -235,6 +253,7 @@ export default function DoctorPage() {
workingStatus: doc.workingStatus, workingStatus: doc.workingStatus,
qualification: doc.qualification, qualification: doc.qualification,
isActive: doc.isActive ?? true, isActive: doc.isActive ?? true,
isFeatured: doc.isFeatured ?? false,
globalSortOrder: doc.globalSortOrder ?? 0, globalSortOrder: doc.globalSortOrder ?? 0,
experience: doc.experience || '', experience: doc.experience || '',
professionalSummary: doc.professionalSummary || '', professionalSummary: doc.professionalSummary || '',
@@ -353,14 +372,15 @@ export default function DoctorPage() {
<CardContent className="p-0 sm:p-6 space-y-4"> <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"> <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"> <TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow> <TableRow>
<TableHead className="w-[80px] bg-background text-sm font-bold">Priority </TableHead> <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-[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-[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-[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> <TableHead className="w-[80px] bg-background text-right text-sm font-bold">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -368,13 +388,13 @@ export default function DoctorPage() {
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <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" /> <Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : currentItems.length === 0 ? ( ) : currentItems.length === 0 ? (
<TableRow> <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 No doctors found
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -423,6 +443,15 @@ export default function DoctorPage() {
</div> </div>
</TableCell> </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"> <TableCell className="text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button size="icon" variant="ghost" className="h-9 w-9" onClick={() => handlePreview(doc)}> <Button size="icon" variant="ghost" className="h-9 w-9" onClick={() => handlePreview(doc)}>
@@ -508,6 +537,17 @@ export default function DoctorPage() {
/> />
</div> </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"> <div className="space-y-1">
<Label htmlFor="globalSortOrder" className="text-sm font-semibold"> <Label htmlFor="globalSortOrder" className="text-sm font-semibold">
Sort Priority (Lower numbers show first) 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, discountedPrice: undefined,
categoryId: 0, categoryId: 0,
isActive: true, isActive: true,
isFeatured: false,
sortOrder: 1000, sortOrder: 1000,
seo: { seo: {
seoTitle: '', seoTitle: '',
@@ -167,6 +168,7 @@ export default function HealthPackagePage() {
discountedPrice: undefined, discountedPrice: undefined,
categoryId: categories[0]?.id || 0, categoryId: categories[0]?.id || 0,
isActive: true, isActive: true,
isFeatured: false,
sortOrder: 1000, sortOrder: 1000,
seo: { seo: {
seoTitle: '', 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">Category</TableHead>
<TableHead className="w-[150px] bg-background text-sm font-bold">Pricing</TableHead> <TableHead className="w-[150px] bg-background text-sm font-bold">Pricing</TableHead>
<TableHead className="w-[120px] bg-background text-sm font-bold">Status</TableHead> <TableHead className="w-[120px] bg-background text-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> <TableHead className="w-[120px] bg-background text-right text-sm font-bold">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -418,6 +421,17 @@ export default function HealthPackagePage() {
</Badge> </Badge>
</div> </div>
</TableCell> </TableCell>
<TableCell>
<Switch
checked={pkg.isFeatured}
onCheckedChange={async () => {
await updateHealthPackageApi(pkg.id!, {
isFeatured: !pkg.isFeatured,
});
fetchData();
}}
/>
</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button