Compare commits

..

15 Commits

Author SHA1 Message Date
Kailasdevdas 98194283df chore: add toast show validation errors 2026-05-18 16:41:38 +05:30
kailasdevdas 1320ce6fe6 Merge pull request 'chore: show required image dimension' (#31) from feat/healthcheckup-crud into dev
Reviewed-on: #31
2026-05-18 07:47:06 +00:00
Kailasdevdas 3dbbb2e77e chore: show required image dimension 2026-05-18 13:15:00 +05:30
kailasdevdas 5b1d626661 Merge pull request 'feat: health checkup CRUD apis' (#30) from feat/healthcheckup-crud into dev
Reviewed-on: #30
2026-05-18 06:26:27 +00:00
Kailasdevdas 098fe12fd7 feat: add image upload for health package 2026-05-18 11:55:55 +05:30
Kailasdevdas 852a25269a feat: add toast 2026-05-18 10:58:24 +05:30
Kailasdevdas d92e0538bd fix: make category slug optional 2026-05-18 10:58:14 +05:30
Kailasdevdas 8d60afdc49 feat: health checkup page 2026-05-15 17:58:25 +05:30
Kailasdevdas 9bc0bf406a feat: health checkup CRUD apis 2026-05-15 17:46:52 +05:30
kailasdevdas 3140d72e28 Merge pull request 'fix:blog editor image upload' (#28) from fix/blog-text-editor-image-uploader into dev
Reviewed-on: #28
2026-05-13 11:49:49 +00:00
rishalkv 6117805467 fix:blog editor image upload 2026-05-13 17:16:25 +05:30
kailasdevdas b002c053ae Merge pull request 'fix: doctor toggle logic' (#27) from feat/appointment-date-filter into dev
Reviewed-on: #27
2026-05-13 09:12:35 +00:00
kailasdevdas e6044518d2 Merge pull request 'feat: update date format in mail' (#26) from feat/email-date-format into dev
Reviewed-on: #26
2026-05-13 09:03:56 +00:00
Kailasdevdas 6889137164 feat: add appointment date range filter 2026-05-13 14:20:51 +05:30
Kailasdevdas 988fbd28f1 fix: doctor toggle logic 2026-05-13 14:19:42 +05:30
28 changed files with 2745 additions and 94 deletions
@@ -0,0 +1,63 @@
-- CreateTable
CREATE TABLE "HealthCheckCategory" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 1000,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HealthCheckCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HealthPackage" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"price" DECIMAL(10,2),
"discountedPrice" DECIMAL(10,2),
"inclusions" JSONB NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 1000,
"categoryId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HealthPackage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HealthPackageInquiry" (
"id" SERIAL NOT NULL,
"fullName" TEXT NOT NULL,
"mobileNumber" TEXT NOT NULL,
"email" TEXT,
"preferredDate" TIMESTAMP(3),
"message" TEXT,
"packageId" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HealthPackageInquiry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "HealthCheckCategory_name_key" ON "HealthCheckCategory"("name");
-- CreateIndex
CREATE UNIQUE INDEX "HealthCheckCategory_slug_key" ON "HealthCheckCategory"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "HealthPackage_slug_key" ON "HealthPackage"("slug");
-- AddForeignKey
ALTER TABLE "HealthPackage" ADD CONSTRAINT "HealthPackage_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "HealthCheckCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HealthPackageInquiry" ADD CONSTRAINT "HealthPackageInquiry_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "HealthPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "HealthPackageInquiry" ADD COLUMN "age" INTEGER,
ADD COLUMN "gender" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "HealthPackage" ALTER COLUMN "inclusions" SET DEFAULT '{}';
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "HealthCheckCategory" ALTER COLUMN "slug" DROP NOT NULL;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "HealthPackage" ADD COLUMN "image" TEXT;
+56
View File
@@ -220,3 +220,59 @@ model NewsImage {
createdAt DateTime @default(now())
}
model HealthCheckCategory {
id Int @id @default(autoincrement())
name String @unique
slug String? @unique
description String?
isActive Boolean @default(true)
sortOrder Int @default(1000)
packages HealthPackage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model HealthPackage {
id Int @id @default(autoincrement())
name String
slug String @unique
description String?
price Decimal? @db.Decimal(10, 2)
image String?
discountedPrice Decimal? @db.Decimal(10, 2)
inclusions Json @default("{}")
isActive Boolean @default(true)
isFeatured Boolean @default(false)
sortOrder Int @default(1000)
categoryId Int
category HealthCheckCategory @relation(fields: [categoryId], references: [id])
inquiries HealthPackageInquiry[]
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
}
+2
View File
@@ -15,6 +15,7 @@ import academicsResearchRoutes from "./routes/academicsResearch.routes.js";
import emailConfigRoutes from "./routes/emailConfig.routes.js";
import newsMediaRoutes from "./routes/newsMedia.routes.js";
import importRoutes from "./routes/importRoutes.js";
import healthCheckRoutes from "./routes/healthCheck.route.js";
dotenv.config();
@@ -55,6 +56,7 @@ app.use("/api/academics", academicsResearchRoutes);
app.use("/api/email", emailConfigRoutes);
app.use("/api/newsMedia", newsMediaRoutes);
app.use("/api/import", importRoutes);
app.use("/api/health-check", healthCheckRoutes);
const PORT = process.env.PORT || 5008;
app.listen(PORT, () => {
@@ -147,18 +147,51 @@ export const getAppointments = async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
const { date, search } = req.query;
const { date, startDate, endDate, search } = req.query;
const where = {};
if (date) {
const hasSingleDate = date && date.trim() !== "";
const hasRange =
(startDate && startDate.trim() !== "") ||
(endDate && endDate.trim() !== "");
if (hasSingleDate) {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
const end = new Date(date);
end.setDate(end.getDate() + 1);
where.date = { gte: start, lt: end };
end.setHours(23, 59, 59, 999);
where.date = {
gte: start,
lte: end,
};
}
if (search) {
if (!hasSingleDate && hasRange) {
const dateFilter = {};
if (startDate && startDate.trim() !== "") {
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
dateFilter.gte = start;
}
if (endDate && endDate.trim() !== "") {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
dateFilter.lte = end;
}
where.date = dateFilter;
}
if (search && search.trim() !== "") {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ mobileNumber: { contains: search } },
@@ -169,24 +202,39 @@ export const getAppointments = async (req, res) => {
const [appointments, total] = await Promise.all([
prisma.appointment.findMany({
where,
include: { doctor: true, department: true },
orderBy: { createdAt: "desc" },
include: {
doctor: true,
department: true,
},
orderBy: {
createdAt: "desc",
},
skip,
take: limit,
}),
prisma.appointment.count({ where }),
prisma.appointment.count({
where,
}),
]);
res.status(200).json({
success: true,
data: appointments,
pagination: { total, page, limit, totalPages: Math.ceil(total / limit) },
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error(error);
res
.status(500)
.json({ success: false, message: "Failed to fetch appointments" });
res.status(500).json({
success: false,
message: "Failed to fetch appointments",
});
}
};
+6 -1
View File
@@ -273,6 +273,11 @@ export const updateDoctor = async (req, res) => {
},
});
const hasTimingData = departments?.some(
(dep) => dep.timing && Object.keys(dep.timing).length > 0,
);
if (departments && Array.isArray(departments) && hasTimingData) {
const oldRelations = await prisma.doctorDepartment.findMany({
where: {doctorId: doctor.id},
});
@@ -304,12 +309,12 @@ export const updateDoctor = async (req, res) => {
if (dep.timing) {
const {id, doctorDepartmentId, createdAt, updatedAt, ...cleanTiming} =
dep.timing;
await prisma.doctorTiming.create({
data: {doctorDepartmentId: newDD.id, ...cleanTiming},
});
}
}
}
res
.status(200)
@@ -0,0 +1,440 @@
import prisma from "../prisma/client.js";
import { sendEmail } from "../utils/sendEmail.js";
import { getEmailsByType } from "../utils/getEmailByTypes.js";
export const getAllCategories = async (req, res) => {
try {
const { admin } = req.query;
const categories = await prisma.healthCheckCategory.findMany({
where: admin === "true" ? {} : { isActive: true },
orderBy: { sortOrder: "asc" },
include: {
_count: { select: { packages: true } },
},
});
return res.status(200).json({ success: true, data: categories });
} catch (error) {
return res
.status(500)
.json({ success: false, message: "Failed to fetch categories" });
}
};
export const createCategory = async (req, res) => {
try {
const { name, slug, description, isActive, sortOrder } = req.body;
const category = await prisma.healthCheckCategory.create({
data: {
name,
slug: slug || null,
description,
isActive: isActive ?? true,
sortOrder: sortOrder ? Number(sortOrder) : 1000,
},
});
return res
.status(201)
.json({ success: true, message: "Category created", data: category });
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to create category" });
}
};
export const updateCategory = async (req, res) => {
try {
const { id } = req.params;
const data = { ...req.body };
delete data.id;
delete data._count;
delete data.createdAt;
delete data.updatedAt;
if (data.sortOrder !== undefined) data.sortOrder = Number(data.sortOrder);
if (data.slug === "") data.slug = null;
const updatedCategory = await prisma.$transaction(async (tx) => {
const category = await tx.healthCheckCategory.update({
where: { id: Number(id) },
data,
});
if (data.isActive === false) {
await tx.healthPackage.updateMany({
where: { categoryId: Number(id) },
data: { isActive: false },
});
}
return category;
});
return res.status(200).json({
success: true,
message: "Category updated",
data: updatedCategory,
});
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to update category" });
}
};
export const deleteCategory = async (req, res) => {
try {
const { id } = req.params;
await prisma.healthCheckCategory.delete({
where: { id: Number(id) },
});
return res
.status(200)
.json({ success: true, message: "Category deleted successfully" });
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message:
"Failed to delete category. Ensure no packages are linked to it.",
});
}
};
export const getAllPackages = async (req, res) => {
try {
const { admin, categorySlug } = req.query;
const packages = await prisma.healthPackage.findMany({
where: {
AND: [
admin === "true" ? {} : { isActive: true },
categorySlug ? { category: { slug: categorySlug } } : {},
],
},
include: { category: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return res.status(200).json({ success: true, data: packages });
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to fetch packages" });
}
};
export const createPackage = async (req, res) => {
try {
const {
name,
slug,
description,
price,
image,
discountedPrice,
inclusions,
categoryId,
isActive,
isFeatured,
sortOrder,
} = req.body;
const healthPackage = await prisma.healthPackage.create({
data: {
name,
slug,
description,
price,
image,
discountedPrice,
inclusions,
categoryId: Number(categoryId),
isActive: isActive ?? true,
isFeatured: isFeatured ?? false,
sortOrder: sortOrder ? Number(sortOrder) : 1000,
},
});
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: `
<div style="font-family: Arial, sans-serif; background-color: #f4f6f8; padding: 20px;">
<div style="max-width: 600px; margin: auto; background: #ffffff; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 10px rgba(0,0,0,0.05);">
<!-- Header -->
<div style="background-color: #0d6efd; color: #ffffff; padding: 20px;">
<h2 style="margin: 0;">GG Hospital</h2>
<p style="margin: 5px 0 0; font-size: 14px;">
New Health Checkup Package Inquiry
</p>
</div>
<!-- Body -->
<div style="padding: 20px; color: #333;">
<h3 style="margin-top: 0;">Inquirer Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; width: 35%;"><b>Name:</b></td>
<td style="padding: 8px 0;">${fullName}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Phone:</b></td>
<td style="padding: 8px 0;">${mobileNumber}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Email:</b></td>
<td style="padding: 8px 0;">${email || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Age:</b></td>
<td style="padding: 8px 0;">${age || "-"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Gender:</b></td>
<td style="padding: 8px 0;">${gender || "-"}</td>
</tr>
</table>
<h3 style="margin-top: 20px;">Package Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; width: 35%;"><b>Package Name:</b></td>
<td style="padding: 8px 0;">${inquiry.healthPackage?.name || "Unknown Package"}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><b>Preferred Date:</b></td>
<td style="padding: 8px 0;">
${
preferredDate
? new Date(preferredDate).toLocaleDateString("en-GB", {
day: "2-digit",
month: "long",
year: "numeric",
})
: "Not specified"
}
</td>
</tr>
</table>
<!-- Message Box -->
<div style="margin-top: 20px;">
<h3>Message</h3>
<div style="
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
">
${message ? message.replace(/\n/g, "<br/>") : "-"}
</div>
</div>
</div>
<!-- Footer -->
<div style="background: #f1f1f1; padding: 15px; text-align: center; font-size: 12px; color: #666;">
This inquiry was submitted via the GG Hospital website.
</div>
</div>
</div>
`,
});
}
} catch (err) {
console.error("Email failed:", err);
}
return res.status(201).json({
success: true,
message: "Booking inquiry sent successfully",
data: inquiry,
});
} catch (error) {
console.error(error);
return res
.status(500)
.json({ success: false, message: "Failed to submit inquiry" });
}
};
export const getPackageBySlug = async (req, res) => {
try {
const { slug } = req.params;
const healthPackage = await prisma.healthPackage.findFirst({
where: { slug, isActive: true },
include: { category: true },
});
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" });
}
};
+39
View File
@@ -0,0 +1,39 @@
import express from "express";
import {
// Categories
getAllCategories,
getPackageBySlug,
createCategory,
updateCategory,
deleteCategory,
// Packages
getAllPackages,
createPackage,
updatePackage,
deletePackage,
// Inquiries
createPackageInquiry,
getAllInquiries,
} from "../controllers/healthCheck.controller.js";
import jwtAuthMiddleware from "../middleware/auth.js";
const router = express.Router();
router.get("/packages", getAllPackages);
router.get("/packages/:slug", getPackageBySlug);
router.get("/categories", getAllCategories);
router.post("/inquiry", createPackageInquiry);
router.get("/inquiries", jwtAuthMiddleware, getAllInquiries);
router.post("/", jwtAuthMiddleware, createPackage);
router.patch("/:id", jwtAuthMiddleware, updatePackage);
router.delete("/:id", jwtAuthMiddleware, deletePackage);
router.post("/categories", jwtAuthMiddleware, createCategory);
router.patch("/categories/:id", jwtAuthMiddleware, updateCategory);
router.delete("/categories/:id", jwtAuthMiddleware, deleteCategory);
export default router;
+2
View File
@@ -23,6 +23,7 @@ import AcademicsPage from "./pages/Academics";
import NewsPage from "./pages/newsMedia";
import BlogDetail from "./pages/BlogDetails";
import ImportData from "./pages/ImportData";
import HealthPackagePage from "./pages/HealthPackagePage";
export default function App() {
return (
@@ -51,6 +52,7 @@ export default function App() {
<Route path="/academics" element={<AcademicsPage />} />
<Route path="/news" element={<NewsPage />} />
<Route path="/import" element={<ImportData />} />
<Route path="/health-check" element={<HealthPackagePage />} />
</Route>
</Route>
+4
View File
@@ -4,12 +4,16 @@ export const getAppointmentsApi = async (
page = 1,
limit = 10,
date = "",
startDate = "",
endDate = "",
search = "",
) => {
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
...(date && { date }),
...(startDate && { startDate }),
...(endDate && { endDate }),
...(search && { search }),
});
const res = await apiClient.get(`/appointments/getall?${params}`);
+3 -1
View File
@@ -8,8 +8,10 @@ export interface Doctor {
designation?: string;
workingStatus?: string;
qualification?: string;
isActive: boolean;
globalSortOrder: number;
departments: {
departments?: {
departmentId: string;
timing?: {
monday?: string;
+160
View File
@@ -0,0 +1,160 @@
import apiClient from "@/api/client";
import toast from "react-hot-toast";
export interface HealthPackage {
id?: number;
name: string;
slug: string;
description?: string;
price: number;
image?: string;
discountedPrice?: number;
inclusions: Record<string, string[]>;
categoryId: number;
isActive: boolean;
isFeatured: boolean;
sortOrder: number;
category?: {
name: string;
};
}
export interface HealthCategory {
id: number;
name: string;
slug: string;
sortOrder: number;
isActive: boolean;
}
export interface HealthInquiry {
id: number;
fullName: string;
mobileNumber: string;
email?: string;
age: string;
gender: string;
preferredDate: string;
message?: string;
createdAt: string;
healthPackage?: {
name: string;
category?: {
name: string;
};
};
}
export const getHealthCategoriesApi = async () => {
const res = await apiClient.get("/health-check/categories?admin=true");
return res.data;
};
export const getHealthPackagesApi = async () => {
const res = await apiClient.get("/health-check/packages?admin=true");
return res.data;
};
export const createHealthPackageApi = async (data: Partial<HealthPackage>) => {
try {
const res = await apiClient.post("/health-check", data);
toast.success("Package created successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to create package");
throw error;
}
};
export const updateHealthPackageApi = async (
id: number,
data: Partial<HealthPackage>,
) => {
try {
const res = await apiClient.patch(`/health-check/${id}`, data);
toast.success("Package updated successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to update package");
throw error;
}
};
export const deleteHealthPackageApi = async (id: number) => {
try {
const res = await apiClient.delete(`/health-check/${id}`);
toast.success("Package deleted successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to delete package");
throw error;
}
};
export const createCategoryApi = async (data: {
name: string;
slug: string;
sortOrder: number;
}) => {
try {
const res = await apiClient.post("/health-check/categories", data);
toast.success("Category created successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to create category");
throw error;
}
};
export const updateCategoryApi = async (id: number, data: any) => {
try {
const res = await apiClient.patch(`/health-check/categories/${id}`, data);
toast.success("Category updated successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to update category");
throw error;
}
};
export const deleteCategoryApi = async (id: number) => {
try {
const res = await apiClient.delete(`/health-check/categories/${id}`);
toast.success("Category deleted successfully");
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || "Failed to delete category");
throw error;
}
};
export const getAllInquiriesApi = async (
page = 1,
limit = 10,
filterDate = "",
startDate = "",
endDate = "",
) => {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
});
if (filterDate) params.append("filterDate", filterDate);
if (startDate) params.append("startDate", startDate);
if (endDate) params.append("endDate", endDate);
const res = await apiClient.get(
`/health-check/inquiries?${params.toString()}`,
);
return res.data;
};
@@ -6,7 +6,12 @@ import axios from "axios";
interface BytescaleUploaderProps {
value: string;
onChange: (url: string) => void;
folderPath: "/doctors" | "/departments" | "/news" | "/blog";
folderPath:
| "/doctors"
| "/departments"
| "/news"
| "/blog"
| "/health-packages";
}
export function BytescaleUploader({
@@ -0,0 +1,273 @@
import { useState, useEffect, useCallback } from "react";
import { getAllInquiriesApi, HealthInquiry } from "@/api/healthCheck";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Loader2, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
export default function PackageInquiriesTab() {
const [inquiries, setInquiries] = useState<HealthInquiry[]>([]);
const [loading, setLoading] = useState(true);
const [filterDate, setFilterDate] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const fetchInquiries = useCallback(async () => {
setLoading(true);
try {
const res = await getAllInquiriesApi(
currentPage,
itemsPerPage,
filterDate,
startDate,
endDate,
);
setInquiries(res.data || []);
setTotalItems(res.pagination?.total || 0);
setTotalPages(res.pagination?.totalPages || 1);
} catch (err) {
console.error("Failed to fetch inquiries", err);
} finally {
setLoading(false);
}
}, [currentPage, itemsPerPage, filterDate, startDate, endDate]);
useEffect(() => {
fetchInquiries();
}, [fetchInquiries]);
const handleFilterChange = (
setter: React.Dispatch<React.SetStateAction<string>>,
value: string,
) => {
setter(value);
setCurrentPage(1);
};
const indexOfFirstItem = (currentPage - 1) * itemsPerPage;
const indexOfLastItem = Math.min(currentPage * itemsPerPage, totalItems);
return (
<Card>
<CardHeader className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<CardTitle className="text-xl">Package Inquiries</CardTitle>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Specific Date
</label>
<Input
type="date"
value={filterDate}
onChange={(e) =>
handleFilterChange(setFilterDate, e.target.value)
}
className="w-[140px] text-sm"
disabled={!!startDate || !!endDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
From
</label>
<Input
type="date"
value={startDate}
onChange={(e) => handleFilterChange(setStartDate, e.target.value)}
className="w-[140px] text-sm"
disabled={!!filterDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
To
</label>
<Input
type="date"
value={endDate}
onChange={(e) => handleFilterChange(setEndDate, e.target.value)}
className="w-[140px] text-sm"
disabled={!!filterDate}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
Rows
</label>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm focus:ring-2 focus:ring-primary">
<option value={5}>5 / page</option>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
</select>
</div>
<Button variant="outline" onClick={fetchInquiries} disabled={loading}>
<RefreshCw
className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
</CardHeader>
<CardContent className="p-0 sm:p-6 sm:pt-0">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[1000px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow>
<TableHead className="w-[150px] font-bold bg-background">
Requested Date
</TableHead>
<TableHead className="w-[220px] font-bold bg-background">
Patient Details
</TableHead>
<TableHead className="w-[250px] font-bold bg-background">
Requested Package
</TableHead>
<TableHead className="w-[120px] font-bold bg-background">
Age/Gender
</TableHead>
<TableHead className="w-[250px] font-bold bg-background">
Message
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : inquiries.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground py-10">
No inquiries found for the selected criteria
</TableCell>
</TableRow>
) : (
inquiries.map((inq) => (
<TableRow key={inq.id} className="hover:bg-muted/50">
<TableCell>
<div className="font-semibold text-primary">
{new Date(inq.preferredDate).toLocaleDateString()}
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Submitted:{" "}
{new Date(inq.createdAt).toLocaleDateString()}
</div>
</TableCell>
<TableCell>
<div className="font-semibold text-base">
{inq.fullName}
</div>
<div className="text-sm">{inq.mobileNumber}</div>
<div className="text-xs text-muted-foreground">
{inq.email || "-"}
</div>
</TableCell>
<TableCell>
<div className="font-semibold text-sm truncate">
{inq.healthPackage?.name || "N/A"}
</div>
</TableCell>
<TableCell>
<div className="font-medium">
{inq.age} yrs / {inq.gender}
</div>
</TableCell>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-sm italic line-clamp-3 text-muted-foreground whitespace-pre-wrap cursor-pointer">
{inq.message || "No message provided."}
</div>
</TooltipTrigger>
<TooltipContent className="max-w-md whitespace-pre-wrap">
{inq.message || "No message provided."}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && totalItems > 0 && (
<div className="flex flex-col sm:flex-row items-center justify-between px-2 py-4 border-t gap-4 mt-2">
<div className="text-sm text-muted-foreground">
Showing{" "}
<span className="font-semibold">{indexOfFirstItem + 1}</span> to{" "}
<span className="font-semibold">{indexOfLastItem}</span> of{" "}
<span className="font-semibold">{totalItems}</span> inquiries
</div>
<div className="flex items-center gap-6">
<div className="text-sm font-semibold">
Page {currentPage} of {totalPages || 1}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-9 w-9"
onClick={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
disabled={currentPage === 1}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages || totalPages === 0}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}
+6 -1
View File
@@ -15,6 +15,10 @@ export default function Sidebar() {
name: "Doctor",
path: "/doctor",
},
{
name: "Health Check",
path: "/health-check",
},
{
name: "Appointments",
path: "/appointment",
@@ -65,7 +69,8 @@ export default function Sidebar() {
<Link key={item.path} to={item.path}>
<Button
variant={active ? "secondary" : "ghost"}
className="w-full justify-start">
className="w-full justify-start"
>
{item.name}
</Button>
</Link>
+2 -2
View File
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
@@ -25,7 +25,7 @@ const buttonVariants = cva(
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+5 -2
View File
@@ -59,7 +59,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
@@ -127,7 +127,10 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
className={cn(
"text-base leading-none font-medium",
className
)}
{...props}
/>
)
+190
View File
@@ -0,0 +1,190 @@
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+88
View File
@@ -0,0 +1,88 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+54 -3
View File
@@ -40,7 +40,8 @@ export default function AppointmentPage() {
const [searchText, setSearchText] = useState("");
const [filterDoctor, setFilterDoctor] = useState("");
const [filterDate, setFilterDate] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [viewOpen, setViewOpen] = useState(false);
const [viewData, setViewData] = useState<any>(null);
@@ -56,6 +57,8 @@ export default function AppointmentPage() {
currentPage,
itemsPerPage,
filterDate,
startDate,
endDate,
searchText,
);
setAppointments(res?.data || []);
@@ -66,7 +69,7 @@ export default function AppointmentPage() {
} finally {
setLoading(false);
}
}, [currentPage, itemsPerPage, filterDate, searchText]);
}, [currentPage, itemsPerPage, filterDate, startDate, endDate, searchText]);
useEffect(() => {
fetchAll();
@@ -116,7 +119,11 @@ export default function AppointmentPage() {
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<h1 className="text-3xl font-bold">Appointments</h1>
<div className="flex flex-wrap gap-3">
<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}
@@ -126,7 +133,12 @@ export default function AppointmentPage() {
}}
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}
@@ -135,8 +147,46 @@ export default function AppointmentPage() {
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) => {
@@ -149,6 +199,7 @@ export default function AppointmentPage() {
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
</select>
</div>
<Button
variant="outline"
+33 -2
View File
@@ -10,6 +10,7 @@ import Table from "@editorjs/table";
import CodeTool from "@editorjs/code";
import Embed from "@editorjs/embed";
import Delimiter from "@editorjs/delimiter";
import axios from "axios";
import {
createBlogApi,
@@ -23,6 +24,7 @@ import {Input} from "@/components/ui/input";
import {Button} from "@/components/ui/button";
export default function BlogEditorPage() {
const baseURL = import.meta.env.VITE_API_URL;
const {id} = useParams();
const navigate = useNavigate();
@@ -79,12 +81,41 @@ export default function BlogEditorPage() {
config: {
uploader: {
uploadByFile: async (file: File) => {
const res = await uploadImageApi(file);
if (file.size > 5 * 1024 * 1024) {
alert("File is too large (Max 5MB)");
return {success: 0, file: {url: ""}};
}
const formData = new FormData();
formData.append("file", file);
formData.append("folderPath", "/blog");
try {
const response = await axios.post(
`${baseURL}/upload`,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
},
);
return {
success: 1,
file: {url: res.file.url},
file: {url: response.data.fileUrl},
};
} catch (e: any) {
console.error("EditorJS Image Upload Error:", e);
const errorMessage =
e.response?.data?.error || e.message || "Upload failed";
alert(`Upload Error: ${errorMessage}`);
return {
success: 0,
file: {url: ""},
};
}
},
},
},
+8 -2
View File
@@ -158,8 +158,14 @@ export default function DoctorPage() {
const handleToggleStatus = async (doc: any) => {
try {
const updatedDoc = { ...doc, isActive: !doc.isActive };
await updateDoctorApi(doc.doctorId, updatedDoc);
const newStatus = !doc.isActive;
const payload = {
isActive: newStatus,
};
await updateDoctorApi(doc.doctorId, payload);
fetchAll();
} catch (err) {
console.error("Failed to update status", err);
File diff suppressed because it is too large Load Diff
+1
View File
@@ -233,6 +233,7 @@ export default function EmailPage() {
<option value="CANDIDATE">CANDIDATE</option>
<option value="ACADEMICS">ACADEMICS</option>
<option value="INQUIRY">INQUIRY</option>
<option value="HCINQUIRY">HC-APPOINTMENT</option>
</select>
<select