From 9bc0bf406a27787c56174ac1f9523e174e301505 Mon Sep 17 00:00:00 2001 From: Kailasdevdas Date: Fri, 15 May 2026 17:46:52 +0530 Subject: [PATCH 1/5] feat: health checkup CRUD apis --- .../migration.sql | 63 +++ .../migration.sql | 3 + .../migration.sql | 2 + backend/prisma/schema.prisma | 55 +++ backend/src/app.js | 2 + .../src/controllers/healthCheck.controller.js | 436 ++++++++++++++++++ backend/src/routes/healthCheck.route.js | 39 ++ 7 files changed, 600 insertions(+) create mode 100644 backend/prisma/migrations/20260513114221_healthcheck_api/migration.sql create mode 100644 backend/prisma/migrations/20260514100534_add_gender_and_age_field/migration.sql create mode 100644 backend/prisma/migrations/20260514110339_default_inclusion/migration.sql create mode 100644 backend/src/controllers/healthCheck.controller.js create mode 100644 backend/src/routes/healthCheck.route.js diff --git a/backend/prisma/migrations/20260513114221_healthcheck_api/migration.sql b/backend/prisma/migrations/20260513114221_healthcheck_api/migration.sql new file mode 100644 index 0000000..224a846 --- /dev/null +++ b/backend/prisma/migrations/20260513114221_healthcheck_api/migration.sql @@ -0,0 +1,63 @@ +-- CreateTable +CREATE TABLE "HealthCheckCategory" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "sortOrder" INTEGER NOT NULL DEFAULT 1000, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "HealthCheckCategory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "HealthPackage" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT, + "price" DECIMAL(10,2), + "discountedPrice" DECIMAL(10,2), + "inclusions" JSONB NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "isFeatured" BOOLEAN NOT NULL DEFAULT false, + "sortOrder" INTEGER NOT NULL DEFAULT 1000, + "categoryId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "HealthPackage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "HealthPackageInquiry" ( + "id" SERIAL NOT NULL, + "fullName" TEXT NOT NULL, + "mobileNumber" TEXT NOT NULL, + "email" TEXT, + "preferredDate" TIMESTAMP(3), + "message" TEXT, + "packageId" INTEGER NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "HealthPackageInquiry_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "HealthCheckCategory_name_key" ON "HealthCheckCategory"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "HealthCheckCategory_slug_key" ON "HealthCheckCategory"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "HealthPackage_slug_key" ON "HealthPackage"("slug"); + +-- AddForeignKey +ALTER TABLE "HealthPackage" ADD CONSTRAINT "HealthPackage_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "HealthCheckCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "HealthPackageInquiry" ADD CONSTRAINT "HealthPackageInquiry_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "HealthPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260514100534_add_gender_and_age_field/migration.sql b/backend/prisma/migrations/20260514100534_add_gender_and_age_field/migration.sql new file mode 100644 index 0000000..299d496 --- /dev/null +++ b/backend/prisma/migrations/20260514100534_add_gender_and_age_field/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "HealthPackageInquiry" ADD COLUMN "age" INTEGER, +ADD COLUMN "gender" TEXT; diff --git a/backend/prisma/migrations/20260514110339_default_inclusion/migration.sql b/backend/prisma/migrations/20260514110339_default_inclusion/migration.sql new file mode 100644 index 0000000..52e227a --- /dev/null +++ b/backend/prisma/migrations/20260514110339_default_inclusion/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "HealthPackage" ALTER COLUMN "inclusions" SET DEFAULT '{}'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b9988d4..d35b4ec 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -220,3 +220,58 @@ model NewsImage { createdAt DateTime @default(now()) } + +model HealthCheckCategory { + id Int @id @default(autoincrement()) + name String @unique + slug String @unique + description String? + isActive Boolean @default(true) + sortOrder Int @default(1000) + + packages HealthPackage[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model HealthPackage { + id Int @id @default(autoincrement()) + name String + slug String @unique + description String? + price Decimal? @db.Decimal(10, 2) + discountedPrice Decimal? @db.Decimal(10, 2) + + inclusions Json @default("{}") + + isActive Boolean @default(true) + isFeatured Boolean @default(false) + sortOrder Int @default(1000) + + categoryId Int + category HealthCheckCategory @relation(fields: [categoryId], references: [id]) + + inquiries HealthPackageInquiry[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model HealthPackageInquiry { + id Int @id @default(autoincrement()) + fullName String + mobileNumber String + email String? + age Int? + gender String? + preferredDate DateTime? + message String? + + packageId Int + healthPackage HealthPackage @relation(fields: [packageId], references: [id]) + + status String @default("pending") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/backend/src/app.js b/backend/src/app.js index 21593f6..0bb2b1f 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -15,6 +15,7 @@ import academicsResearchRoutes from "./routes/academicsResearch.routes.js"; import emailConfigRoutes from "./routes/emailConfig.routes.js"; import newsMediaRoutes from "./routes/newsMedia.routes.js"; import importRoutes from "./routes/importRoutes.js"; +import healthCheckRoutes from "./routes/healthCheck.route.js"; dotenv.config(); @@ -55,6 +56,7 @@ app.use("/api/academics", academicsResearchRoutes); app.use("/api/email", emailConfigRoutes); app.use("/api/newsMedia", newsMediaRoutes); app.use("/api/import", importRoutes); +app.use("/api/health-check", healthCheckRoutes); const PORT = process.env.PORT || 5008; app.listen(PORT, () => { diff --git a/backend/src/controllers/healthCheck.controller.js b/backend/src/controllers/healthCheck.controller.js new file mode 100644 index 0000000..6803063 --- /dev/null +++ b/backend/src/controllers/healthCheck.controller.js @@ -0,0 +1,436 @@ +import prisma from "../prisma/client.js"; +import { sendEmail } from "../utils/sendEmail.js"; +import { getEmailsByType } from "../utils/getEmailByTypes.js"; + +export const getAllCategories = async (req, res) => { + try { + const { admin } = req.query; + + const categories = await prisma.healthCheckCategory.findMany({ + where: admin === "true" ? {} : { isActive: true }, + orderBy: { sortOrder: "asc" }, + include: { + _count: { select: { packages: true } }, + }, + }); + return res.status(200).json({ success: true, data: categories }); + } catch (error) { + return res + .status(500) + .json({ success: false, message: "Failed to fetch categories" }); + } +}; + +export const createCategory = async (req, res) => { + try { + const { name, slug, description, isActive, sortOrder } = req.body; + + const category = await prisma.healthCheckCategory.create({ + data: { + name, + slug, + description, + isActive: isActive ?? true, + sortOrder: sortOrder ? Number(sortOrder) : 1000, + }, + }); + + return res + .status(201) + .json({ success: true, message: "Category created", data: category }); + } catch (error) { + console.error(error); + return res + .status(500) + .json({ success: false, message: "Failed to create category" }); + } +}; + +export const updateCategory = async (req, res) => { + try { + const { id } = req.params; + const data = { ...req.body }; + + delete data.id; + delete data._count; + delete data.createdAt; + delete data.updatedAt; + + if (data.sortOrder !== undefined) data.sortOrder = Number(data.sortOrder); + + const updatedCategory = await prisma.$transaction(async (tx) => { + const category = await tx.healthCheckCategory.update({ + where: { id: Number(id) }, + data, + }); + + if (data.isActive === false) { + await tx.healthPackage.updateMany({ + where: { categoryId: Number(id) }, + data: { isActive: false }, + }); + } + + return category; + }); + + return res.status(200).json({ + success: true, + message: "Category updated", + data: updatedCategory, + }); + } catch (error) { + console.error(error); + return res + .status(500) + .json({ success: false, message: "Failed to update category" }); + } +}; + +export const deleteCategory = async (req, res) => { + try { + const { id } = req.params; + + await prisma.healthCheckCategory.delete({ + where: { id: Number(id) }, + }); + + return res + .status(200) + .json({ success: true, message: "Category deleted successfully" }); + } catch (error) { + console.error(error); + return res.status(500).json({ + success: false, + message: + "Failed to delete category. Ensure no packages are linked to it.", + }); + } +}; + +export const getAllPackages = async (req, res) => { + try { + const { admin, categorySlug } = req.query; + + const packages = await prisma.healthPackage.findMany({ + where: { + AND: [ + admin === "true" ? {} : { isActive: true }, + categorySlug ? { category: { slug: categorySlug } } : {}, + ], + }, + include: { category: true }, + orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], + }); + + return res.status(200).json({ success: true, data: packages }); + } catch (error) { + console.error(error); + return res + .status(500) + .json({ success: false, message: "Failed to fetch packages" }); + } +}; + +export const createPackage = async (req, res) => { + try { + const { + name, + slug, + description, + price, + discountedPrice, + inclusions, + categoryId, + isActive, + isFeatured, + sortOrder, + } = req.body; + + const healthPackage = await prisma.healthPackage.create({ + data: { + name, + slug, + description, + price, + discountedPrice, + inclusions, + categoryId: Number(categoryId), + isActive: isActive ?? true, + isFeatured: isFeatured ?? false, + sortOrder: sortOrder ? Number(sortOrder) : 1000, + }, + }); + + return res + .status(201) + .json({ success: true, message: "Package created", data: healthPackage }); + } catch (error) { + console.error(error); + return res + .status(500) + .json({ success: false, message: "Failed to create package" }); + } +}; + +export const updatePackage = async (req, res) => { + try { + const { id } = req.params; + const data = { ...req.body }; + delete data.id; + delete data.category; + + if (data.categoryId) data.categoryId = Number(data.categoryId); + if (data.sortOrder) data.sortOrder = Number(data.sortOrder); + + const updated = await prisma.healthPackage.update({ + where: { id: Number(id) }, + data, + }); + + return res + .status(200) + .json({ success: true, message: "Package updated", data: updated }); + } catch (error) { + console.error(error); + return res.status(500).json({ success: false, message: "Update failed" }); + } +}; + +export const deletePackage = async (req, res) => { + try { + const { id } = req.params; + await prisma.healthPackage.delete({ where: { id: Number(id) } }); + return res.status(200).json({ success: true, message: "Package deleted" }); + } catch (error) { + console.error(error); + return res.status(500).json({ success: false, message: "Delete failed" }); + } +}; + +export const createPackageInquiry = async (req, res) => { + try { + const { + fullName, + mobileNumber, + email, + age, + gender, + preferredDate, + packageId, + message, + } = req.body; + + const inquiry = await prisma.healthPackageInquiry.create({ + data: { + fullName, + mobileNumber, + email, + age: age ? Number(age) : null, + gender, + preferredDate: preferredDate ? new Date(preferredDate) : null, + message, + packageId: Number(packageId), + }, + include: { + healthPackage: true, + }, + }); + + try { + const emailList = await getEmailsByType("HCINQUIRY"); + + if (emailList) { + await sendEmail({ + to: emailList, + subject: "New Health Checkup Package Inquiry", + html: ` +
+ +
+ + +
+

GG Hospital

+

+ New Health Checkup Package Inquiry +

+
+ + +
+ +

Inquirer Details

+ + + + + + + + + + + + + + + + + + + + + + +
Name:${fullName}
Phone:${mobileNumber}
Email:${email || "-"}
Age:${age || "-"}
Gender:${gender || "-"}
+ +

Package Details

+ + + + + + + + + + +
Package Name:${inquiry.healthPackage?.name || "Unknown Package"}
Preferred Date: + ${ + preferredDate + ? new Date(preferredDate).toLocaleDateString("en-GB", { + day: "2-digit", + month: "long", + year: "numeric", + }) + : "Not specified" + } +
+ + +
+

Message

+
+ ${message ? message.replace(/\n/g, "
") : "-"} +
+
+ +
+ + +
+ This inquiry was submitted via the GG Hospital website. +
+ +
+ +
+ `, + }); + } + } catch (err) { + console.error("Email failed:", err); + } + + return res.status(201).json({ + success: true, + message: "Booking inquiry sent successfully", + data: inquiry, + }); + } catch (error) { + console.error(error); + return res + .status(500) + .json({ success: false, message: "Failed to submit inquiry" }); + } +}; + +export const getPackageBySlug = async (req, res) => { + try { + const { slug } = req.params; + const healthPackage = await prisma.healthPackage.findFirst({ + where: { slug, isActive: true }, + include: { category: true }, + }); + + if (!healthPackage) { + return res + .status(404) + .json({ success: false, message: "Package not found" }); + } + + return res.status(200).json({ success: true, data: healthPackage }); + } catch (error) { + console.error(error); + return res + .status(500) + .json({ success: false, message: "Failed to fetch package" }); + } +}; + +export const getAllInquiries = async (req, res) => { + try { + const { page = 1, limit = 10, filterDate, startDate, endDate } = req.query; + + const queryPage = parseInt(page); + const queryLimit = parseInt(limit); + const skip = (queryPage - 1) * queryLimit; + + let where = {}; + + if (filterDate) { + where.preferredDate = { + gte: new Date(`${filterDate}T00:00:00.000Z`), + lte: new Date(`${filterDate}T23:59:59.999Z`), + }; + } else if (startDate || endDate) { + where.preferredDate = {}; + if (startDate) { + where.preferredDate.gte = new Date(`${startDate}T00:00:00.000Z`); + } + if (endDate) { + where.preferredDate.lte = new Date(`${endDate}T23:59:59.999Z`); + } + } + + const [total, inquiries] = await prisma.$transaction([ + prisma.healthPackageInquiry.count({ where }), + prisma.healthPackageInquiry.findMany({ + where, + skip, + take: queryLimit, + include: { + healthPackage: { + include: { category: true }, + }, + }, + orderBy: { createdAt: "desc" }, + }), + ]); + + return res.status(200).json({ + success: true, + data: inquiries, + pagination: { + total, + page: queryPage, + limit: queryLimit, + totalPages: Math.ceil(total / queryLimit), + }, + }); + } catch (error) { + console.error(error); + return res + .status(500) + .json({ success: false, message: "Failed to fetch inquiries" }); + } +}; diff --git a/backend/src/routes/healthCheck.route.js b/backend/src/routes/healthCheck.route.js new file mode 100644 index 0000000..2049dcd --- /dev/null +++ b/backend/src/routes/healthCheck.route.js @@ -0,0 +1,39 @@ +import express from "express"; +import { + // Categories + getAllCategories, + getPackageBySlug, + createCategory, + updateCategory, + deleteCategory, + + // Packages + getAllPackages, + createPackage, + updatePackage, + deletePackage, + + // Inquiries + createPackageInquiry, + getAllInquiries, +} from "../controllers/healthCheck.controller.js"; + +import jwtAuthMiddleware from "../middleware/auth.js"; + +const router = express.Router(); + +router.get("/packages", getAllPackages); +router.get("/packages/:slug", getPackageBySlug); +router.get("/categories", getAllCategories); +router.post("/inquiry", createPackageInquiry); + +router.get("/inquiries", jwtAuthMiddleware, getAllInquiries); +router.post("/", jwtAuthMiddleware, createPackage); +router.patch("/:id", jwtAuthMiddleware, updatePackage); +router.delete("/:id", jwtAuthMiddleware, deletePackage); + +router.post("/categories", jwtAuthMiddleware, createCategory); +router.patch("/categories/:id", jwtAuthMiddleware, updateCategory); +router.delete("/categories/:id", jwtAuthMiddleware, deleteCategory); + +export default router; From 8d60afdc49795e0da7665625f6d99359df6d0ad0 Mon Sep 17 00:00:00 2001 From: Kailasdevdas Date: Fri, 15 May 2026 17:58:25 +0530 Subject: [PATCH 2/5] feat: health checkup page --- frontend/src/App.tsx | 2 + frontend/src/api/healthCheck.ts | 132 +++ .../PackageInquiriesTab.tsx | 273 +++++ frontend/src/components/layout/Sidebar.tsx | 7 +- frontend/src/components/ui/button.tsx | 4 +- frontend/src/components/ui/dialog.tsx | 7 +- frontend/src/components/ui/select.tsx | 190 +++ frontend/src/components/ui/tabs.tsx | 88 ++ frontend/src/components/ui/tooltip.tsx | 55 + frontend/src/pages/HealthPackagePage.tsx | 1041 +++++++++++++++++ frontend/src/pages/email.tsx | 1 + 11 files changed, 1795 insertions(+), 5 deletions(-) create mode 100644 frontend/src/api/healthCheck.ts create mode 100644 frontend/src/components/PackageInquiriesTab/PackageInquiriesTab.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/components/ui/tooltip.tsx create mode 100644 frontend/src/pages/HealthPackagePage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54fcdf9..bfc0b15 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import AcademicsPage from "./pages/Academics"; import NewsPage from "./pages/newsMedia"; import BlogDetail from "./pages/BlogDetails"; import ImportData from "./pages/ImportData"; +import HealthPackagePage from "./pages/HealthPackagePage"; export default function App() { return ( @@ -51,6 +52,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/healthCheck.ts b/frontend/src/api/healthCheck.ts new file mode 100644 index 0000000..698e26b --- /dev/null +++ b/frontend/src/api/healthCheck.ts @@ -0,0 +1,132 @@ +import apiClient from "@/api/client"; +import toast from "react-hot-toast"; + +export interface HealthPackage { + id?: number; + name: string; + slug: string; + description?: string; + price: number; + discountedPrice?: number; + inclusions: Record; + categoryId: number; + isActive: boolean; + isFeatured: boolean; + sortOrder: number; + category?: { + name: string; + }; +} + +export interface HealthCategory { + id: number; + name: string; + slug: string; + sortOrder: number; + isActive: boolean; +} + +export interface HealthInquiry { + id: number; + fullName: string; + mobileNumber: string; + email?: string; + age: string; + gender: string; + preferredDate: string; + message?: string; + createdAt: string; + healthPackage?: { + name: string; + category?: { + name: string; + }; + }; +} + +export const getHealthCategoriesApi = async () => { + const res = await apiClient.get("/health-check/categories?admin=true"); + return res.data; +}; + +export const getHealthPackagesApi = async () => { + const res = await apiClient.get("/health-check/packages?admin=true"); + return res.data; +}; + +export const createHealthPackageApi = async (data: Partial) => { + try { + const res = await apiClient.post("/health-check", data); + toast.success("Package created successfully"); + return res.data; + } catch (error: any) { + toast.error(error?.response?.data?.message || "Failed to create package"); + throw error; + } +}; + +export const updateHealthPackageApi = async ( + id: number, + data: Partial, +) => { + try { + const res = await apiClient.patch(`/health-check/${id}`, data); + toast.success("Package updated successfully"); + return res.data; + } catch (error: any) { + toast.error(error?.response?.data?.message || "Failed to update package"); + throw error; + } +}; + +export const deleteHealthPackageApi = async (id: number) => { + try { + const res = await apiClient.delete(`/health-check/${id}`); + toast.success("Package deleted successfully"); + return res.data; + } catch (error: any) { + toast.error(error?.response?.data?.message || "Failed to delete package"); + throw error; + } +}; + +export const createCategoryApi = async (data: { + name: string; + slug: string; + sortOrder: number; +}) => { + const res = await apiClient.post("/health-check/categories", data); + return res.data; +}; + +export const updateCategoryApi = async (id: number, data: any) => { + const res = await apiClient.patch(`/health-check/categories/${id}`, data); + return res.data; +}; + +export const deleteCategoryApi = async (id: number) => { + const res = await apiClient.delete(`/health-check/categories/${id}`); + return res.data; +}; + +export const getAllInquiriesApi = async ( + page = 1, + limit = 10, + filterDate = "", + startDate = "", + endDate = "", +) => { + const params = new URLSearchParams({ + page: page.toString(), + limit: limit.toString(), + }); + + if (filterDate) params.append("filterDate", filterDate); + if (startDate) params.append("startDate", startDate); + if (endDate) params.append("endDate", endDate); + + const res = await apiClient.get( + `/health-check/inquiries?${params.toString()}`, + ); + return res.data; +}; diff --git a/frontend/src/components/PackageInquiriesTab/PackageInquiriesTab.tsx b/frontend/src/components/PackageInquiriesTab/PackageInquiriesTab.tsx new file mode 100644 index 0000000..0d13eff --- /dev/null +++ b/frontend/src/components/PackageInquiriesTab/PackageInquiriesTab.tsx @@ -0,0 +1,273 @@ +import { useState, useEffect, useCallback } from "react"; +import { getAllInquiriesApi, HealthInquiry } from "@/api/healthCheck"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react"; + +export default function PackageInquiriesTab() { + const [inquiries, setInquiries] = useState([]); + const [loading, setLoading] = useState(true); + + const [filterDate, setFilterDate] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(10); + const [totalItems, setTotalItems] = useState(0); + const [totalPages, setTotalPages] = useState(1); + + const fetchInquiries = useCallback(async () => { + setLoading(true); + try { + const res = await getAllInquiriesApi( + currentPage, + itemsPerPage, + filterDate, + startDate, + endDate, + ); + setInquiries(res.data || []); + setTotalItems(res.pagination?.total || 0); + setTotalPages(res.pagination?.totalPages || 1); + } catch (err) { + console.error("Failed to fetch inquiries", err); + } finally { + setLoading(false); + } + }, [currentPage, itemsPerPage, filterDate, startDate, endDate]); + + useEffect(() => { + fetchInquiries(); + }, [fetchInquiries]); + + const handleFilterChange = ( + setter: React.Dispatch>, + value: string, + ) => { + setter(value); + setCurrentPage(1); + }; + + const indexOfFirstItem = (currentPage - 1) * itemsPerPage; + const indexOfLastItem = Math.min(currentPage * itemsPerPage, totalItems); + + return ( + + + Package Inquiries + +
+
+ + + handleFilterChange(setFilterDate, e.target.value) + } + className="w-[140px] text-sm" + disabled={!!startDate || !!endDate} + /> +
+ +
+ + handleFilterChange(setStartDate, e.target.value)} + className="w-[140px] text-sm" + disabled={!!filterDate} + /> +
+ +
+ + handleFilterChange(setEndDate, e.target.value)} + className="w-[140px] text-sm" + disabled={!!filterDate} + /> +
+ +
+ + +
+ + +
+
+ +
+ + + + + Requested Date + + + Patient Details + + + Requested Package + + + Age/Gender + + + Message + + + + + {loading ? ( + + + + + + ) : inquiries.length === 0 ? ( + + + No inquiries found for the selected criteria + + + ) : ( + inquiries.map((inq) => ( + + +
+ {new Date(inq.preferredDate).toLocaleDateString()} +
+
+ Submitted:{" "} + {new Date(inq.createdAt).toLocaleDateString()} +
+
+ +
+ {inq.fullName} +
+
{inq.mobileNumber}
+
+ {inq.email || "-"} +
+
+ +
+ {inq.healthPackage?.name || "N/A"} +
+
+ +
+ {inq.age} yrs / {inq.gender} +
+
+ + + +
+ {inq.message || "No message provided."} +
+
+ + + {inq.message || "No message provided."} + +
+
+
+ )) + )} +
+
+
+ + {!loading && totalItems > 0 && ( +
+
+ Showing{" "} + {indexOfFirstItem + 1} to{" "} + {indexOfLastItem} of{" "} + {totalItems} inquiries +
+
+
+ Page {currentPage} of {totalPages || 1} +
+
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 2610239..b59650a 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -15,6 +15,10 @@ export default function Sidebar() { name: "Doctor", path: "/doctor", }, + { + name: "Health Check", + path: "/health-check", + }, { name: "Appointments", path: "/appointment", @@ -65,7 +69,8 @@ export default function Sidebar() { diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index c88ffd6..6138844 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { Slot } from "radix-ui" import { cn } from "@/lib/utils" const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { @@ -25,7 +25,7 @@ const buttonVariants = cva( "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", icon: "size-8", "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index f4cbece..c44b1db 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -59,7 +59,7 @@ function DialogContent({ ) diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..8333850 --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -0,0 +1,190 @@ +import * as React from "react" +import { Select as SelectPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..72465b2 --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,88 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Tabs as TabsPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/frontend/src/components/ui/tooltip.tsx b/frontend/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..7413f6e --- /dev/null +++ b/frontend/src/components/ui/tooltip.tsx @@ -0,0 +1,55 @@ +import * as React from "react" +import { Tooltip as TooltipPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/frontend/src/pages/HealthPackagePage.tsx b/frontend/src/pages/HealthPackagePage.tsx new file mode 100644 index 0000000..d436269 --- /dev/null +++ b/frontend/src/pages/HealthPackagePage.tsx @@ -0,0 +1,1041 @@ +import { useState, useEffect, useCallback, useMemo } from "react"; +import { AxiosError } from "axios"; + +import { + getHealthPackagesApi, + getHealthCategoriesApi, + createHealthPackageApi, + updateHealthPackageApi, + createCategoryApi, + updateCategoryApi, + deleteCategoryApi, + HealthPackage, + HealthCategory, +} from "@/api/healthCheck"; + +import PackageInquiriesTab from "@/components/PackageInquiriesTab/PackageInquiriesTab"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +import { + Loader2, + RefreshCw, + Plus, + Pencil, + ChevronLeft, + ChevronRight, + LayoutGrid, + Eye, + Trash2, +} from "lucide-react"; + +export default function HealthPackagePage() { + const [packages, setPackages] = useState([]); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + // Modals + const [packageModal, setPackageModal] = useState(false); + const [categoryModal, setCategoryModal] = useState(false); + const [viewModal, setViewModal] = useState(false); + + // States + const [selectedPackage, setSelectedPackage] = useState( + null, + ); + const [editingPackage, setEditingPackage] = useState( + null, + ); + const [editingCategory, setEditingCategory] = useState( + null, + ); + + // Filters & Pagination + const [searchText, setSearchText] = useState(""); + const [filterCategory, setFilterCategory] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 10; + + // Forms + const [pkgForm, setPkgForm] = useState>({ + name: "", + slug: "", + description: "", + price: 0, + discountedPrice: 0, + categoryId: 0, + isActive: true, + sortOrder: 1000, + }); + const [inclusionsList, setInclusionsList] = useState([ + { id: Date.now(), category: "", items: "" }, + ]); + const [catForm, setCatForm] = useState({ + name: "", + slug: "", + sortOrder: 1000, + isActive: true, + }); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(""); + try { + const [p, c] = await Promise.all([ + getHealthPackagesApi(), + getHealthCategoriesApi(), + ]); + setPackages(p.data || []); + setCategories(c.data || []); + } catch (err) { + if (err instanceof AxiosError) { + setError(err.response?.data?.message || "Failed to load data"); + } else { + setError("Something went wrong"); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // --- Package Filtering & Pagination --- + const filteredPackages = useMemo(() => { + return packages.filter((pkg) => { + const matchesSearch = + pkg.name.toLowerCase().includes(searchText.toLowerCase()) || + pkg.category?.name.toLowerCase().includes(searchText.toLowerCase()); + const matchesCat = filterCategory + ? pkg.categoryId === Number(filterCategory) + : true; + return matchesSearch && matchesCat; + }); + }, [packages, searchText, filterCategory]); + + useEffect(() => { + setCurrentPage(1); + }, [searchText, filterCategory]); + + const totalPages = Math.ceil(filteredPackages.length / itemsPerPage); + const indexOfLastItem = currentPage * itemsPerPage; + const indexOfFirstItem = indexOfLastItem - itemsPerPage; + const currentItems = filteredPackages.slice( + indexOfFirstItem, + indexOfLastItem, + ); + + // --- Actions --- + const handleToggleStatus = async (pkg: HealthPackage) => { + if (!pkg.id) return; + try { + await updateHealthPackageApi(pkg.id, { isActive: !pkg.isActive }); + fetchData(); + } catch (err) { + console.error("Failed to update status", err); + } + }; + const handleToggleCategoryStatus = async (cat: HealthCategory) => { + if (!cat.id) return; + try { + if (cat.isActive) { + const proceed = window.confirm( + "Hiding this category will also hide all packages inside it. Proceed?", + ); + if (!proceed) return; + } + + await updateCategoryApi(cat.id, { isActive: !cat.isActive }); + fetchData(); + } catch (err) { + console.error("Failed to update category status", err); + } + }; + + const openAddPackage = () => { + setEditingPackage(null); + setPkgForm({ + name: "", + slug: "", + description: "", + price: 0, + discountedPrice: 0, + categoryId: categories[0]?.id || 0, + isActive: true, + sortOrder: 1000, + }); + setInclusionsList([{ id: Date.now(), category: "", items: "" }]); + setPackageModal(true); + }; + + const openEditPackage = (pkg: any) => { + setEditingPackage(pkg); + setPkgForm(pkg); + + if ( + pkg.inclusions && + typeof pkg.inclusions === "object" && + !Array.isArray(pkg.inclusions) + ) { + const formattedList = Object.entries(pkg.inclusions).map( + ([cat, items], idx) => ({ + id: Date.now() + idx, + category: cat, + items: (items as string[]).join(", "), + }), + ); + setInclusionsList( + formattedList.length + ? formattedList + : [{ id: Date.now(), category: "", items: "" }], + ); + } else { + setInclusionsList([{ id: Date.now(), category: "", items: "" }]); + } + + setPackageModal(true); + }; + const handleAddInclusionField = () => { + setInclusionsList([ + ...inclusionsList, + { id: Date.now(), category: "", items: "" }, + ]); + }; + + const handleRemoveInclusionField = (id: number) => { + setInclusionsList(inclusionsList.filter((item) => item.id !== id)); + }; + + const handleUpdateInclusionField = ( + id: number, + field: string, + value: string, + ) => { + setInclusionsList( + inclusionsList.map((item) => + item.id === id ? { ...item, [field]: value } : item, + ), + ); + }; + + const savePackage = async () => { + try { + // Convert the dynamic array back into the required JSON object format + const parsedInclusions: Record = {}; + inclusionsList.forEach((entry) => { + const catName = entry.category.trim(); + if (catName) { + parsedInclusions[catName] = entry.items + .split(",") + .map((i) => i.trim()) + .filter(Boolean); + } + }); + + const finalData = { ...pkgForm, inclusions: parsedInclusions }; + + if (editingPackage?.id) { + const changedFields: Record = {}; + Object.keys(finalData).forEach((key) => { + const k = key as keyof HealthPackage; + if ( + JSON.stringify(finalData[k]) !== JSON.stringify(editingPackage[k]) + ) { + changedFields[k] = finalData[k]; + } + }); + + delete changedFields.id; + delete changedFields.category; + + if (Object.keys(changedFields).length === 0) { + setPackageModal(false); + return; + } + + await updateHealthPackageApi(editingPackage.id, changedFields); + } else { + await createHealthPackageApi(finalData); + } + + setPackageModal(false); + fetchData(); + } catch (err) { + console.error(err); + } + }; + + const saveCategory = async () => { + try { + if (editingCategory?.id) { + const changedFields: Record = {}; + + Object.keys(catForm).forEach((key) => { + const k = key as keyof HealthCategory; + if (catForm[k] !== editingCategory[k]) { + changedFields[k] = catForm[k]; + } + }); + + delete changedFields.id; + delete changedFields._count; + + if (Object.keys(changedFields).length === 0) { + setCategoryModal(false); + return; + } + + await updateCategoryApi( + editingCategory.id, + changedFields as Partial, + ); + } else { + await createCategoryApi(catForm as any); + } + + setCategoryModal(false); + fetchData(); + } catch (err) { + console.error(err); + } + }; + + const deleteCategory = async (id: number) => { + if (confirm("Delete this category? Ensure no packages are linked to it.")) { + await deleteCategoryApi(id); + fetchData(); + } + }; + + return ( +
+
+

Health Packages

+ +
+ setSearchText(e.target.value)} + className="w-[250px] text-base" + /> + + + + + + +
+
+ + {error && ( +
+ {error} +
+ )} + + + + Packages + Categories + Inquiries + + + {/* PACKAGES TAB */} + + + + Package List + + +
+ + + + + Priority + + + Package Details + + + Category + + + Pricing + + + Status + + + Actions + + + + + {loading ? ( + + + + + + ) : currentItems.length === 0 ? ( + + + No packages found + + + ) : ( + currentItems.map((pkg) => ( + + + {pkg.sortOrder} + + +
+ {pkg.name} +
+
+ /{pkg.slug} +
+
+ + + {pkg.category?.name} + + + +
+ ₹{pkg.discountedPrice || pkg.price} +
+ {pkg.discountedPrice && + pkg.discountedPrice < pkg.price && ( +
+ ₹{pkg.price} +
+ )} +
+ +
+ handleToggleStatus(pkg)} + /> + + {pkg.isActive ? "Active" : "Hidden"} + +
+
+ +
+ + +
+
+
+ )) + )} +
+
+
+ + {!loading && filteredPackages.length > 0 && ( +
+
+ Showing{" "} + + {indexOfFirstItem + 1} + {" "} + to{" "} + + {Math.min(indexOfLastItem, filteredPackages.length)} + {" "} + of{" "} + + {filteredPackages.length} + {" "} + packages +
+
+
+ Page {currentPage} of {totalPages} +
+
+ + +
+
+
+ )} +
+
+
+ + {/* CATEGORIES TAB */} + + + + Category List + + + +
+ + + + + Priority + + + Category Name + + + Status + + + Actions + + + + + {categories.map((cat) => ( + + + {cat.sortOrder} + + + {cat.name} + + + +
+ + handleToggleCategoryStatus(cat) + } + /> + + {cat.isActive ? "Active" : "Hidden"} + +
+
+ +
+ +
+
+
+ ))} +
+
+
+
+
+
+ + + +
+ + {/* --- PACKAGE MODAL --- */} + + + + + {editingPackage ? "Edit Package" : "Add Package"} + + + +
+
+
+

+ Profile & Pricing +

+
+
+ + + setPkgForm({ ...pkgForm, isActive: val }) + } + /> +
+ +
+ + + setPkgForm({ + ...pkgForm, + sortOrder: Number(e.target.value), + }) + } + className="text-base" + /> +
+ +
+ + + setPkgForm({ ...pkgForm, name: e.target.value }) + } + className="text-base" + /> +
+ +
+ + + setPkgForm({ ...pkgForm, slug: e.target.value }) + } + className="text-base" + /> +
+ +
+ + +
+ +
+
+ + + setPkgForm({ + ...pkgForm, + price: Number(e.target.value), + }) + } + className="text-base" + /> +
+
+ + + setPkgForm({ + ...pkgForm, + discountedPrice: Number(e.target.value), + }) + } + className="text-base" + /> +
+
+
+
+ +
+

+ Details & Inclusions +

+
+
+ +