diff --git a/backend/prisma/migrations/20260622101915_accreditation/migration.sql b/backend/prisma/migrations/20260622101915_accreditation/migration.sql new file mode 100644 index 0000000..0c21349 --- /dev/null +++ b/backend/prisma/migrations/20260622101915_accreditation/migration.sql @@ -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") +); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0baea7d..993825d 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -356,4 +356,25 @@ 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 } \ No newline at end of file diff --git a/backend/src/app.js b/backend/src/app.js index 4e6257b..98da273 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -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, () => { diff --git a/backend/src/controllers/accreditation.controller.js b/backend/src/controllers/accreditation.controller.js new file mode 100644 index 0000000..0f6fffe --- /dev/null +++ b/backend/src/controllers/accreditation.controller.js @@ -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', + }); + } +}; diff --git a/backend/src/routes/accreditation.routes.js b/backend/src/routes/accreditation.routes.js new file mode 100644 index 0000000..11499e2 --- /dev/null +++ b/backend/src/routes/accreditation.routes.js @@ -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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5f0d0ba..71001d3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/frontend/src/api/accreditation.ts b/frontend/src/api/accreditation.ts new file mode 100644 index 0000000..0af4c36 --- /dev/null +++ b/frontend/src/api/accreditation.ts @@ -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) => { + 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) => { + 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; + } +}; diff --git a/frontend/src/components/AccreditationModal/AccreditationModal.tsx b/frontend/src/components/AccreditationModal/AccreditationModal.tsx new file mode 100644 index 0000000..5bc901a --- /dev/null +++ b/frontend/src/components/AccreditationModal/AccreditationModal.tsx @@ -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 ( + + + {/* Header */} + + + {editingAccreditation ? 'Edit Accreditation / Certification' : 'Create Accreditation / Certification'} + + + + {/* Body */} + + {/* Basic Information */} + + + Basic Information + Configure accreditation or certification details. + + + + + Category + + + setAccreditationForm({ + ...accreditationForm, + type: value, + }) + } + > + + + + + + Accreditation + + Certification + + + + + + Title + + + setAccreditationForm({ + ...accreditationForm, + title: e.target.value, + }) + } + /> + + + + + Description (Optional) + + + setAccreditationForm({ + ...accreditationForm, + description: e.target.value, + }) + } + /> + + + + {/* Media Upload */} + + + Media Assets + + Upload logo and certificate images. + + + + + Logo (Optional) + + Recommended transparent PNG/SVG logo. + + + setAccreditationForm({ + ...accreditationForm, + logo: url, + }) + } + /> + + + + Certificate Image (Optional) + + Upload certificate, award or recognition image. + + + setAccreditationForm({ + ...accreditationForm, + image: url, + }) + } + /> + + + + + {/* Display Settings */} + + + Display Settings + + + + + Priority + + + setAccreditationForm({ + ...accreditationForm, + sortOrder: Number(e.target.value), + }) + } + /> + + + + + Active Visibility + + + + setAccreditationForm({ + ...accreditationForm, + isActive: value, + }) + } + /> + + + + + + {/* Footer */} + + onOpenChange(false)}> + Cancel + + + + {editingAccreditation ? 'Save Changes' : 'Create Item'} + + + + + ); +} diff --git a/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx b/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx index bb230cf..0c446d8 100644 --- a/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx +++ b/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx @@ -15,7 +15,8 @@ interface BytescaleUploaderProps { | '/blog' | '/doctor-og' | '/homepage-banners' - | '/insurance-partners'; + | '/insurance-partners' + | '/accreditations'; } export function BytescaleUploader({ value, onChange, folderPath }: BytescaleUploaderProps) { diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 9165057..28702c5 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -64,6 +64,7 @@ export default function Sidebar() { name: 'Insurance Partner', path: '/insurance-partner', }, + { name: 'Accreditation', path: '/accreditation' }, ], }, ]; diff --git a/frontend/src/pages/Accreditation.tsx b/frontend/src/pages/Accreditation.tsx new file mode 100644 index 0000000..b48f4ed --- /dev/null +++ b/frontend/src/pages/Accreditation.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const [modalOpen, setModalOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + + const [searchText, setSearchText] = useState(''); + const [categoryFilter, setCategoryFilter] = useState(''); + + const [form, setForm] = useState>({ + 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 = {}; + + 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 ( + + {/* Header */} + + + Accreditations & Certifications + + + + setSearchText(e.target.value)} + className="w-[250px]" + /> + + setCategoryFilter(e.target.value)} + className="h-10 rounded-md border px-3" + > + All Categories + + Accreditations + + Certifications + + + + + Refresh + + + + + Add Item + + + + + {error && {error}} + + + + Accreditation Directory + + + + + + + + Order + + Logo + + Details + + Category + + Status + + Actions + + + + + {loading ? ( + + + + + + ) : filteredItems.length === 0 ? ( + + + No accreditation records found. + + + ) : ( + filteredItems.map((item) => ( + + {item.sortOrder} + + + + {item.logo ? ( + + ) : ( + + )} + + + + + {item.title} + + + {item.description || 'No description provided'} + + + {item.image && ( + + + View certificate image + + + )} + + + + {item.type} + + + + + handleToggleStatus(item)} /> + + + {item.isActive ? 'Active' : 'Hidden'} + + + + + + + openEdit(item)}> + + + + item.id && handleDelete(item.id)} + > + + + + + + )) + )} + + + + + + + + + ); +}
Configure accreditation or certification details.
Upload logo and certificate images.
Recommended transparent PNG/SVG logo.
Upload certificate, award or recognition image.
Active Visibility