Compare commits

...

6 Commits

20 changed files with 1097 additions and 20 deletions
@@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "AccreditationType" AS ENUM ('ACCREDITATION', 'CERTIFICATION');
-- CreateTable
CREATE TABLE "Accreditation" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"type" "AccreditationType" NOT NULL,
"logo" TEXT,
"image" TEXT,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 1000,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Accreditation_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Doctor" ADD COLUMN "isFeatured" BOOLEAN NOT NULL DEFAULT false;
+22
View File
@@ -27,6 +27,7 @@ model Doctor {
workingStatus String?
qualification String?
isActive Boolean @default(true)
isFeatured Boolean @default(false)
globalSortOrder Int @default(1000)
specializations DoctorSpecialization[]
professionalSummary String? @db.Text
@@ -357,3 +358,24 @@ model InsurancePartner {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Accreditation {
id Int @id @default(autoincrement())
title String
type AccreditationType
logo String?
image String?
description String? @db.Text
sortOrder Int @default(1000)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum AccreditationType {
ACCREDITATION
CERTIFICATION
}
+2
View File
@@ -18,6 +18,7 @@ import importRoutes from './routes/importRoutes.js';
import healthCheckRoutes from './routes/healthCheck.route.js';
import homepageBannerRoutes from './routes/homepageBanner.routes.js';
import insurancePartnerRoutes from './routes/insurancePartner.routes.js';
import accreditationRoutes from './routes/accreditation.routes.js';
dotenv.config();
@@ -61,6 +62,7 @@ app.use('/api/import', importRoutes);
app.use('/api/health-check', healthCheckRoutes);
app.use('/api/homepage-banners', homepageBannerRoutes);
app.use('/api/insurance-partners', insurancePartnerRoutes);
app.use('/api/accreditation', accreditationRoutes);
const PORT = process.env.PORT || 5008;
app.listen(PORT, () => {
@@ -0,0 +1,189 @@
import prisma from '../prisma/client.js';
export const createAccreditation = async (req, res) => {
try {
const { title, type, logo, image, description, sortOrder, isActive } = req.body;
if (!title || !type) {
return res.status(400).json({
success: false,
message: 'Title and type are required',
});
}
const accreditation = await prisma.accreditation.create({
data: {
title,
type,
logo,
image,
description,
sortOrder: sortOrder ? Number(sortOrder) : 1000,
isActive: isActive ?? true,
},
});
res.status(201).json({
success: true,
data: accreditation,
message: 'Accreditation created successfully',
});
} catch (error) {
console.error('PRISMA TRANSACTION ERROR:', error);
res.status(500).json({
success: false,
message: 'Failed to create accreditation',
});
}
};
export const getAccreditations = async (req, res) => {
try {
const { type } = req.query;
const accreditations = await prisma.accreditation.findMany({
where: type
? {
type,
}
: undefined,
orderBy: {
sortOrder: 'asc',
},
});
res.json({
success: true,
data: accreditations,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch accreditations',
});
}
};
export const getActiveAccreditations = async (req, res) => {
try {
const { type } = req.query;
const accreditations = await prisma.accreditation.findMany({
where: {
isActive: true,
...(type && { type }),
},
orderBy: {
sortOrder: 'asc',
},
});
res.json({
success: true,
data: accreditations,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch accreditations',
});
}
};
export const getAccreditation = async (req, res) => {
try {
const { id } = req.params;
const accreditation = await prisma.accreditation.findUnique({
where: {
id: Number(id),
},
});
if (!accreditation) {
return res.status(404).json({
success: false,
message: 'Accreditation not found',
});
}
res.json({
success: true,
data: accreditation,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch accreditation',
});
}
};
export const updateAccreditation = async (req, res) => {
try {
const { id } = req.params;
const { title, type, logo, image, description, sortOrder, isActive } = req.body;
const updateData = {};
if (title !== undefined) updateData.title = title;
if (type !== undefined) updateData.type = type;
if (logo !== undefined) updateData.logo = logo;
if (image !== undefined) updateData.image = image;
if (description !== undefined) updateData.description = description;
if (sortOrder !== undefined) updateData.sortOrder = Number(sortOrder);
if (isActive !== undefined) updateData.isActive = isActive;
const accreditation = await prisma.accreditation.update({
where: {
id: Number(id),
},
data: updateData,
});
res.json({
success: true,
data: accreditation,
message: 'Accreditation updated successfully',
});
} catch (error) {
console.error('PRISMA TRANSACTION ERROR:', error);
res.status(500).json({
success: false,
message: 'Failed to update accreditation',
});
}
};
export const deleteAccreditation = async (req, res) => {
try {
const { id } = req.params;
await prisma.accreditation.delete({
where: {
id: Number(id),
},
});
res.json({
success: true,
message: 'Accreditation deleted successfully',
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to delete accreditation',
});
}
};
+69 -1
View File
@@ -34,6 +34,7 @@ export const getAllDoctors = async (req, res) => {
workingStatus: doc.workingStatus,
qualification: doc.qualification,
isActive: doc.isActive,
isFeatured: doc.isFeatured,
experience: doc.experience,
professionalSummary: doc.professionalSummary,
globalSortOrder: doc.globalSortOrder,
@@ -129,6 +130,7 @@ export const getDoctorByDoctorId = async (req, res) => {
experience: doctor.experience,
professionalSummary: doctor.professionalSummary,
isActive: doctor.isActive,
isFeatured: doctor.isFeatured,
seo: {
seoTitle: doctor.seo?.seoTitle ?? '',
metaDescription: doctor.seo?.metaDescription ?? '',
@@ -240,6 +242,7 @@ export const createDoctor = async (req, res) => {
workingStatus,
qualification,
isActive,
isFeatured,
globalSortOrder,
departments,
experience,
@@ -297,6 +300,7 @@ export const createDoctor = async (req, res) => {
professionalSummary,
seoId: seo.id,
isActive: isActive !== undefined ? isActive : true,
isFeatured: isFeatured !== undefined ? isFeatured : false,
globalSortOrder: globalSortOrder !== undefined ? Number(globalSortOrder) : 0,
},
});
@@ -361,6 +365,7 @@ export const updateDoctor = async (req, res) => {
workingStatus,
qualification,
isActive,
isFeatured,
globalSortOrder,
departments,
experience,
@@ -397,6 +402,19 @@ export const updateDoctor = async (req, res) => {
message: `Doctor has been ${doctor.isActive ? 'deactivated' : 'activated'} successfully`,
});
}
if (action === 'toggleFeatured') {
await prisma.doctor.update({
where: { id: doctor.id },
data: {
isFeatured: !doctor.isFeatured,
},
});
return res.status(200).json({
success: true,
message: `Doctor has been ${doctor.isFeatured ? 'removed from featured' : 'marked as featured'} successfully`,
});
}
const messages = [];
if (!doctorId) messages.push('Doctor ID is required');
@@ -423,7 +441,8 @@ export const updateDoctor = async (req, res) => {
image,
workingStatus,
qualification,
isActive,
isActive: isActive !== undefined ? isActive : undefined,
isFeatured: isFeatured !== undefined ? isFeatured : undefined,
experience: experience ? Number(experience) : null,
professionalSummary,
globalSortOrder: globalSortOrder !== undefined ? Number(globalSortOrder) : undefined,
@@ -700,3 +719,52 @@ export const getDoctorTimingById = async (req, res) => {
});
}
};
export const getFeaturedDoctors = async (req, res) => {
try {
const doctors = await prisma.doctor.findMany({
where: {
isActive: true,
isFeatured: true,
},
include: {
seo: {
select: {
slug: true,
},
},
departments: {
include: {
department: true,
},
},
},
orderBy: [{ globalSortOrder: 'asc' }, { name: 'asc' }],
});
const data = doctors.map((doc) => ({
doctorId: doc.doctorId,
name: doc.name,
image: doc.image ?? '',
designation: doc.designation,
qualification: doc.qualification,
experience: doc.experience,
slug: doc.seo?.slug ?? '',
departments: doc.departments.map((d) => ({
departmentId: d.department.departmentId,
departmentName: d.department.name,
})),
}));
res.status(200).json({
success: true,
data,
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
message: 'Failed to fetch featured doctors',
});
}
};
@@ -486,3 +486,33 @@ export const getAllInquiries = async (req, res) => {
return res.status(500).json({ success: false, message: 'Failed to fetch inquiries' });
}
};
export const getFeaturedPackages = async (req, res) => {
try {
const packages = await prisma.healthPackage.findMany({
where: {
isActive: true,
isFeatured: true,
category: {
isActive: true,
},
},
include: {
category: true,
seo: true,
},
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
return res.status(200).json({
success: true,
data: packages,
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: 'Failed to fetch featured packages',
});
}
};
@@ -0,0 +1,24 @@
import express from 'express';
import {
createAccreditation,
getAccreditations,
getActiveAccreditations,
getAccreditation,
updateAccreditation,
deleteAccreditation,
} from '../controllers/accreditation.controller.js';
import jwtAuthMiddleware from '../middleware/auth.js';
const router = express.Router();
router.get('/active', getActiveAccreditations);
router.post('/', jwtAuthMiddleware, createAccreditation);
router.get('/getAll', jwtAuthMiddleware, getAccreditations);
router.get('/:id', jwtAuthMiddleware, getAccreditation);
router.put('/:id', jwtAuthMiddleware, updateAccreditation);
router.delete('/:id', jwtAuthMiddleware, deleteAccreditation);
export default router;
+2
View File
@@ -8,6 +8,7 @@ import {
getDoctorTimingById,
getDoctorByDoctorId,
getDoctorsByDepartmentId,
getFeaturedDoctors,
} from '../controllers/doctor.controller.js';
import jwtAuthMiddleware from '../middleware/auth.js';
@@ -18,6 +19,7 @@ router.get('/getAll', getAllDoctors);
router.get('/search', getDoctorsByDepartmentId);
router.get('/getTimings', getDoctorTimings);
router.get('/getTimings/:doctorId', getDoctorTimingById);
router.get('/featured', getFeaturedDoctors);
router.get('/:doctorId', getDoctorByDoctorId);
router.post('/', jwtAuthMiddleware, createDoctor);
+2
View File
@@ -12,6 +12,7 @@ import {
createPackage,
updatePackage,
deletePackage,
getFeaturedPackages,
// Inquiries
createPackageInquiry,
@@ -26,6 +27,7 @@ router.get('/packages', getAllPackages);
router.get('/packages/:slug', getPackageBySlug);
router.get('/categories', getAllCategories);
router.post('/inquiry', createPackageInquiry);
router.get('/featured', getFeaturedPackages);
router.get('/inquiries', jwtAuthMiddleware, getAllInquiries);
router.post('/', jwtAuthMiddleware, createPackage);
+2
View File
@@ -26,6 +26,7 @@ import ImportData from './pages/ImportData';
import HealthPackagePage from './pages/HealthPackagePage';
import HomepageBanner from './pages/HomepageBannerPage';
import InsurancePartnerPage from './pages/InsurancePartner';
import AccreditationPage from './pages/Accreditation';
export default function App() {
return (
@@ -57,6 +58,7 @@ export default function App() {
<Route path="/health-check" element={<HealthPackagePage />} />
<Route path="/homepage-banner" element={<HomepageBanner />} />
<Route path="/insurance-partner" element={<InsurancePartnerPage />} />
<Route path="/accreditation" element={<AccreditationPage />} />
</Route>
</Route>
+85
View File
@@ -0,0 +1,85 @@
import apiClient from '@/api/client';
import toast from 'react-hot-toast';
export type AccreditationType = 'ACCREDITATION' | 'CERTIFICATION';
export interface Accreditation {
id?: number;
title: string;
type: AccreditationType;
logo?: string;
image?: string;
description?: string;
sortOrder: number;
isActive: boolean;
createdAt?: string;
updatedAt?: string;
}
export const getAccreditationsApi = async (type?: AccreditationType) => {
const query = type ? `?type=${type}` : '';
const res = await apiClient.get(`/accreditation/getAll${query}`);
return res.data;
};
export const getAccreditationApi = async (id: number) => {
const res = await apiClient.get(`/accreditation/${id}`);
return res.data;
};
export const getActiveAccreditationsApi = async (type?: AccreditationType) => {
const query = type ? `?type=${type}` : '';
const res = await apiClient.get(`/accreditation/active${query}`);
return res.data;
};
export const createAccreditationApi = async (data: Partial<Accreditation>) => {
try {
const res = await apiClient.post('/accreditation', data);
toast.success('Accreditation created successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to create accreditation');
throw error;
}
};
export const updateAccreditationApi = async (id: number, data: Partial<Accreditation>) => {
try {
const res = await apiClient.put(`/accreditation/${id}`, data);
toast.success('Accreditation updated successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to update accreditation');
throw error;
}
};
export const deleteAccreditationApi = async (id: number) => {
try {
const res = await apiClient.delete(`/accreditation/${id}`);
toast.success('Accreditation deleted successfully');
return res.data;
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to delete accreditation');
throw error;
}
};
+2 -1
View File
@@ -9,6 +9,7 @@ export interface Doctor {
workingStatus?: string;
qualification?: string;
isActive: boolean;
isFeatured: boolean;
globalSortOrder: number;
departments?: {
@@ -53,7 +54,7 @@ export const createDoctorApi = async (data: Doctor) => {
export const updateDoctorApi = async (
doctorId: string,
data: Partial<Doctor>,
action: 'toggleStatus' | 'updateDetails' = 'updateDetails'
action: 'toggleStatus' | 'toggleFeatured' | 'updateDetails' = 'updateDetails'
) => {
try {
const res = await apiClient.patch(`/doctors/${doctorId}/${action}`, data);
@@ -0,0 +1,209 @@
import { BytescaleUploader } from '@/components/BytescaleUploader/BytescaleUploader';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
editingAccreditation: any;
accreditationForm: any;
setAccreditationForm: any;
onSave: () => void;
}
export default function AccreditationModal({
open,
onOpenChange,
editingAccreditation,
accreditationForm,
setAccreditationForm,
onSave,
}: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full !max-w-3xl h-auto max-h-[90vh] flex flex-col p-0 overflow-hidden">
{/* Header */}
<DialogHeader className="px-6 py-5 border-b bg-background sticky top-0 z-20">
<DialogTitle className="text-2xl font-bold">
{editingAccreditation ? 'Edit Accreditation / Certification' : 'Create Accreditation / Certification'}
</DialogTitle>
</DialogHeader>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{/* Basic Information */}
<div className="space-y-5">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Basic Information</h3>
<p className="text-sm text-muted-foreground">Configure accreditation or certification details.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-2">
<Label className="font-semibold">Category</Label>
<Select
value={accreditationForm.type}
onValueChange={(value) =>
setAccreditationForm({
...accreditationForm,
type: value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCREDITATION">Accreditation</SelectItem>
<SelectItem value="CERTIFICATION">Certification</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="font-semibold">Title</Label>
<Input
value={accreditationForm.title || ''}
placeholder="e.g. NABH, NABL, ISO 9001"
onChange={(e) =>
setAccreditationForm({
...accreditationForm,
title: e.target.value,
})
}
/>
</div>
</div>
<div className="space-y-2">
<Label className="font-semibold">Description (Optional)</Label>
<Textarea
rows={4}
value={accreditationForm.description || ''}
placeholder="Short description about this accreditation or certificate"
onChange={(e) =>
setAccreditationForm({
...accreditationForm,
description: e.target.value,
})
}
/>
</div>
</div>
{/* Media Upload */}
<div className="space-y-5">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Media Assets</h3>
<p className="text-sm text-muted-foreground">Upload logo and certificate images.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="font-semibold">Logo (Optional)</Label>
<p className="text-xs text-muted-foreground">Recommended transparent PNG/SVG logo.</p>
<BytescaleUploader
value={accreditationForm.logo || ''}
folderPath="/accreditations"
onChange={(url) =>
setAccreditationForm({
...accreditationForm,
logo: url,
})
}
/>
</div>
<div className="space-y-2">
<Label className="font-semibold">Certificate Image (Optional)</Label>
<p className="text-xs text-muted-foreground">Upload certificate, award or recognition image.</p>
<BytescaleUploader
value={accreditationForm.image || ''}
folderPath="/accreditations"
onChange={(url) =>
setAccreditationForm({
...accreditationForm,
image: url,
})
}
/>
</div>
</div>
</div>
{/* Display Settings */}
<div className="space-y-5">
<div className="border-b pb-2">
<h3 className="text-lg font-bold">Display Settings</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-2">
<Label className="font-semibold">Priority</Label>
<Input
type="number"
value={accreditationForm.sortOrder}
onChange={(e) =>
setAccreditationForm({
...accreditationForm,
sortOrder: Number(e.target.value),
})
}
/>
</div>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Active Visibility</p>
</div>
<Switch
checked={accreditationForm.isActive}
onCheckedChange={(value) =>
setAccreditationForm({
...accreditationForm,
isActive: value,
})
}
/>
</div>
</div>
</div>
</div>
{/* Footer */}
<DialogFooter className="p-6 border-t bg-background sticky bottom-0 z-20">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button className="px-10" onClick={onSave}>
{editingAccreditation ? 'Save Changes' : 'Create Item'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -15,7 +15,8 @@ interface BytescaleUploaderProps {
| '/blog'
| '/doctor-og'
| '/homepage-banners'
| '/insurance-partners';
| '/insurance-partners'
| '/accreditations';
}
export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUploaderProps) {
@@ -120,22 +120,40 @@ export default function HealthPackageModal({
/>
</div>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Active Visibility</p>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Active</p>
<p className="text-sm text-muted-foreground">Show publicly</p>
</div>
<p className="text-sm text-muted-foreground">Show this package publicly</p>
<Switch
checked={pkgForm.isActive}
onCheckedChange={(val) =>
setPkgForm({
...pkgForm,
isActive: val,
})
}
/>
</div>
<Switch
checked={pkgForm.isActive}
onCheckedChange={(val) =>
setPkgForm({
...pkgForm,
isActive: val,
})
}
/>
<div className="flex items-center justify-between border rounded-xl p-4 bg-muted/30">
<div>
<p className="font-semibold">Featured</p>
<p className="text-sm text-muted-foreground">Show on homepage</p>
</div>
<Switch
checked={pkgForm.isFeatured || false}
onCheckedChange={(val) =>
setPkgForm({
...pkgForm,
isFeatured: val,
})
}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -64,6 +64,7 @@ export default function Sidebar() {
name: 'Insurance Partner',
path: '/insurance-partner',
},
{ name: 'Accreditation', path: '/accreditation' },
],
},
];
+347
View File
@@ -0,0 +1,347 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import toast from 'react-hot-toast';
import { AxiosError } from 'axios';
import {
getAccreditationsApi,
createAccreditationApi,
updateAccreditationApi,
deleteAccreditationApi,
Accreditation,
} from '@/api/accreditation';
import AccreditationModal from '@/components/AccreditationModal/AccreditationModal';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Loader2, RefreshCw, Plus, Pencil, Trash2, Award } from 'lucide-react';
export default function AccreditationPage() {
const [items, setItems] = useState<Accreditation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<Accreditation | null>(null);
const [searchText, setSearchText] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [form, setForm] = useState<Partial<Accreditation>>({
title: '',
type: 'ACCREDITATION',
logo: '',
image: '',
description: '',
sortOrder: 1000,
isActive: true,
});
const fetchData = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await getAccreditationsApi();
setItems(res.data || []);
} catch (err) {
if (err instanceof AxiosError) {
setError(err.response?.data?.message || 'Failed to fetch accreditation records');
} else {
setError('Something went wrong');
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const filteredItems = useMemo(() => {
return items.filter((item) => {
const matchesSearch = item.title.toLowerCase().includes(searchText.toLowerCase());
const matchesCategory = categoryFilter ? item.type === categoryFilter : true;
return matchesSearch && matchesCategory;
});
}, [items, searchText, categoryFilter]);
const handleToggleStatus = async (item: Accreditation) => {
if (!item.id) return;
try {
await updateAccreditationApi(item.id, {
isActive: !item.isActive,
});
toast.success('Status updated');
fetchData();
} catch (err) {
console.error(err);
toast.error('Failed to update status');
}
};
const handleDelete = async (id: number) => {
const confirmDelete = window.confirm('Delete this accreditation permanently?');
if (!confirmDelete) return;
try {
await deleteAccreditationApi(id);
fetchData();
} catch (err) {
console.error(err);
}
};
const openAdd = () => {
setEditingItem(null);
setForm({
title: '',
type: 'ACCREDITATION',
logo: '',
image: '',
description: '',
sortOrder: 1000,
isActive: true,
});
setModalOpen(true);
};
const openEdit = (item: Accreditation) => {
setEditingItem(item);
setForm({
...item,
});
setModalOpen(true);
};
const saveItem = async () => {
if (!form.title?.trim()) {
return toast.error('Title is required');
}
if (!form.type) {
return toast.error('Category is required');
}
try {
if (editingItem?.id) {
const changedFields: Record<string, any> = {};
Object.keys(form).forEach((key) => {
const k = key as keyof Accreditation;
if (JSON.stringify(form[k]) !== JSON.stringify(editingItem[k])) {
changedFields[k] = form[k];
}
});
delete changedFields.id;
delete changedFields.createdAt;
delete changedFields.updatedAt;
if (Object.keys(changedFields).length === 0) {
setModalOpen(false);
return;
}
await updateAccreditationApi(editingItem.id, changedFields);
} else {
await createAccreditationApi(form);
}
setModalOpen(false);
fetchData();
} catch (err) {
console.error(err);
toast.error('Failed to save accreditation');
}
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex flex-col md:flex-row md:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">Accreditations & Certifications</h1>
</div>
<div className="flex flex-wrap gap-3">
<Input
placeholder="Search title..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-[250px]"
/>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="h-10 rounded-md border px-3"
>
<option value="">All Categories</option>
<option value="ACCREDITATION">Accreditations</option>
<option value="CERTIFICATION">Certifications</option>
</select>
<Button variant="outline" onClick={fetchData} disabled={loading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
<Button onClick={openAdd}>
<Plus className="mr-2 h-4 w-4" />
Add Item
</Button>
</div>
</div>
{error && <div className="p-4 text-red-600 bg-red-50 rounded">{error}</div>}
<Card>
<CardHeader>
<CardTitle>Accreditation Directory</CardTitle>
</CardHeader>
<CardContent className="p-0 sm:p-6">
<div className="border rounded-md overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Order</TableHead>
<TableHead>Logo</TableHead>
<TableHead>Details</TableHead>
<TableHead>Category</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : filteredItems.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-10">
No accreditation records found.
</TableCell>
</TableRow>
) : (
filteredItems.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="font-mono">{item.sortOrder}</TableCell>
<TableCell>
<div className="w-20 h-16 border rounded-md overflow-hidden bg-white flex items-center justify-center">
{item.logo ? (
<img src={item.logo} alt={item.title} className="w-full h-full object-contain" />
) : (
<Award className="h-6 w-6 text-muted-foreground" />
)}
</div>
</TableCell>
<TableCell>
<div className="font-semibold">{item.title}</div>
<div className="text-sm text-muted-foreground line-clamp-2">
{item.description || 'No description provided'}
</div>
{item.image && (
<div className="mt-2">
<a
href={item.image}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline"
>
View certificate image
</a>
</div>
)}
</TableCell>
<TableCell>
<Badge variant={item.type === 'ACCREDITATION' ? 'default' : 'secondary'}>{item.type}</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch checked={item.isActive} onCheckedChange={() => handleToggleStatus(item)} />
<Badge variant={item.isActive ? 'default' : 'secondary'}>
{item.isActive ? 'Active' : 'Hidden'}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button size="icon" variant="ghost" onClick={() => openEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-red-500 hover:text-red-600"
onClick={() => item.id && handleDelete(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<AccreditationModal
open={modalOpen}
onOpenChange={setModalOpen}
editingAccreditation={editingItem}
accreditationForm={form}
setAccreditationForm={setForm}
onSave={saveItem}
/>
</div>
);
}
+44 -4
View File
@@ -51,6 +51,7 @@ export default function DoctorPage() {
workingStatus: '',
qualification: '',
isActive: true,
isFeatured: false,
globalSortOrder: 0,
departments: [],
professionalSummary: '',
@@ -156,6 +157,22 @@ export default function DoctorPage() {
}
};
const handleToggleFeatured = async (doc: any) => {
try {
const newFeaturedStatus = !doc.isFeatured;
const payload = {
isFeatured: newFeaturedStatus,
};
await updateDoctorApi(doc.doctorId, payload, 'toggleFeatured');
fetchAll();
} catch (err) {
console.error('Failed to update featured status', err);
}
};
function handleDepartmentToggle(depId: string) {
const exists = form.departments.find((d: any) => d.departmentId === depId);
if (exists) {
@@ -201,6 +218,7 @@ export default function DoctorPage() {
experience: '',
professionalSummary: '',
isActive: true,
isFeatured: false,
globalSortOrder: 0,
specializations: [
{
@@ -235,6 +253,7 @@ export default function DoctorPage() {
workingStatus: doc.workingStatus,
qualification: doc.qualification,
isActive: doc.isActive ?? true,
isFeatured: doc.isFeatured ?? false,
globalSortOrder: doc.globalSortOrder ?? 0,
experience: doc.experience || '',
professionalSummary: doc.professionalSummary || '',
@@ -353,14 +372,15 @@ export default function DoctorPage() {
<CardContent className="p-0 sm:p-6 space-y-4">
<div className="rounded-md border overflow-x-auto overflow-y-auto max-h-[650px] relative">
<Table className="w-full min-w-[1100px] table-fixed border-separate border-spacing-0">
<Table className="w-full min-w-[1200px] table-fixed border-separate border-spacing-0">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow>
<TableHead className="w-[80px] bg-background text-sm font-bold">Priority </TableHead>
<TableHead className="w-[180px] bg-background text-sm font-bold">Doctor Info</TableHead>
<TableHead className="w-[150px] bg-background text-sm font-bold">Designation</TableHead>
<TableHead className="w-[220px] bg-background text-sm font-bold">Departments (Hierarchy)</TableHead>
<TableHead className="w-[80px] bg-background text-sm font-bold">Status (Active)</TableHead>
<TableHead className="w-[100px] bg-background text-sm font-bold">Status (Active)</TableHead>
<TableHead className="w-[100px] bg-background text-sm font-bold">Featured</TableHead>
<TableHead className="w-[80px] bg-background text-right text-sm font-bold">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -368,13 +388,13 @@ export default function DoctorPage() {
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-10">
<TableCell colSpan={7} className="text-center py-10">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : currentItems.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-10 text-base">
<TableCell colSpan={7} className="text-center text-muted-foreground py-10 text-base">
No doctors found
</TableCell>
</TableRow>
@@ -423,6 +443,15 @@ export default function DoctorPage() {
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch checked={doc.isFeatured || false} onCheckedChange={() => handleToggleFeatured(doc)} />
<Badge variant={doc.isFeatured ? 'default' : 'secondary'}>
{doc.isFeatured ? 'Featured' : 'Hidden'}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button size="icon" variant="ghost" className="h-9 w-9" onClick={() => handlePreview(doc)}>
@@ -508,6 +537,17 @@ export default function DoctorPage() {
/>
</div>
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/30">
<Label htmlFor="isFeatured" className="text-base font-semibold cursor-pointer">
Featured
</Label>
<Switch
id="isFeatured"
checked={form.isFeatured}
onCheckedChange={(val) => setForm({ ...form, isFeatured: val })}
/>
</div>
<div className="space-y-1">
<Label htmlFor="globalSortOrder" className="text-sm font-semibold">
Sort Priority (Lower numbers show first)
+14
View File
@@ -62,6 +62,7 @@ export default function HealthPackagePage() {
discountedPrice: undefined,
categoryId: 0,
isActive: true,
isFeatured: false,
sortOrder: 1000,
seo: {
seoTitle: '',
@@ -167,6 +168,7 @@ export default function HealthPackagePage() {
discountedPrice: undefined,
categoryId: categories[0]?.id || 0,
isActive: true,
isFeatured: false,
sortOrder: 1000,
seo: {
seoTitle: '',
@@ -366,6 +368,7 @@ export default function HealthPackagePage() {
<TableHead className="w-[150px] bg-background text-sm font-bold">Category</TableHead>
<TableHead className="w-[150px] bg-background text-sm font-bold">Pricing</TableHead>
<TableHead className="w-[120px] bg-background text-sm font-bold">Status</TableHead>
<TableHead className="w-[100px] bg-background text-sm font-bold">Featured</TableHead>
<TableHead className="w-[120px] bg-background text-right text-sm font-bold">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -418,6 +421,17 @@ export default function HealthPackagePage() {
</Badge>
</div>
</TableCell>
<TableCell>
<Switch
checked={pkg.isFeatured}
onCheckedChange={async () => {
await updateHealthPackageApi(pkg.id!, {
isFeatured: !pkg.isFeatured,
});
fetchData();
}}
/>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button