Dental Care

This commit is contained in:
Iliyan Angelov
2025-11-16 14:29:51 +02:00
commit 39077550ef
194 changed files with 43197 additions and 0 deletions

View File

@@ -0,0 +1,235 @@
"use client";
import * as React from "react";
import Image from "next/image";
import {
IconChartBar,
IconDashboard,
IconDatabase,
IconFileDescription,
IconHelp,
IconListDetails,
IconReport,
IconSettings,
IconUsers,
IconStethoscope,
IconCalendar,
IconMedicalCross,
IconUserCog,
} from "@tabler/icons-react";
import { NavDocuments } from "@/components/layout/nav-documents";
import { NavMain } from "@/components/layout/nav-main";
import { NavSecondary } from "@/components/layout/nav-secondary";
import { NavUser } from "@/components/layout/nav-user";
import Link from "next/link";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
const adminData = {
navMain: [
{
title: "Dashboard",
url: "/admin",
icon: IconDashboard,
},
{
title: "Appointments",
url: "/admin/appointment-management",
icon: IconCalendar,
},
{
title: "Patients",
url: "/admin/patient-management",
icon: IconUsers,
},
{
title: "Dentists",
url: "/admin/dentist-management",
icon: IconStethoscope,
},
{
title: "Services",
url: "/admin/service-management",
icon: IconMedicalCross,
},
{
title: "Users",
url: "/admin/user-management",
icon: IconUserCog,
},
],
navSecondary: [
{
title: "Settings",
url: "/admin/settings",
icon: IconSettings,
},
{
title: "Help & Support",
url: "/admin/help-support",
icon: IconHelp,
},
],
documents: [
{
name: "Analytics",
url: "/Dashboard",
icon: IconChartBar,
},
{
name: "Reports",
url: "/Reports",
icon: IconReport,
},
],
};
const patientData = {
navMain: [
{
title: "Dashboard",
url: "/patient",
icon: IconDashboard,
},
{
title: "Book Appointment",
url: "/patient/book-appointment",
icon: IconCalendar,
},
{
title: "My Appointments",
url: "/patient/appointments",
icon: IconListDetails,
},
{
title: "Payments",
url: "/patient/payments",
icon: IconFileDescription,
},
{
title: "Health Records",
url: "/patient/health-records",
icon: IconDatabase,
},
],
navSecondary: [
{
title: "Settings",
url: "/patient/settings",
icon: IconSettings,
},
{
title: "Help & Support",
url: "#",
icon: IconHelp,
},
],
documents: [],
};
const dentistData = {
navMain: [
{
title: "Dashboard",
url: "/dentist",
icon: IconDashboard,
},
{
title: "My Appointments",
url: "/dentist/appointments",
icon: IconCalendar,
},
{
title: "My Patients",
url: "/dentist/patients",
icon: IconUsers,
},
{
title: "Schedule",
url: "/dentist/schedule",
icon: IconListDetails,
},
],
navSecondary: [
{
title: "Settings",
url: "/dentist/settings",
icon: IconSettings,
},
{
title: "Help & Support",
url: "#",
icon: IconHelp,
},
],
documents: [],
};
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
user?: {
id: string;
name: string;
email: string;
image?: string | null;
role?: string | null;
} | null;
isAdmin?: boolean;
};
export function AppSidebar({ user, isAdmin, ...props }: AppSidebarProps) {
// Determine which data to use based on user role
const role = user?.role || "patient";
const data =
role === "admin"
? adminData
: role === "dentist"
? dentistData
: patientData;
const homeUrl =
role === "admin" ? "/admin" : role === "dentist" ? "/dentist" : "/";
return (
<>
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="data-[slot=sidebar-menu-button]:!p-1.5"
>
<Link href={homeUrl} className="flex items-center gap-2">
<Image
src="/tooth.svg"
alt="Dental U Care"
width={24}
height={24}
className="!size-6"
/>
<span className="text-base font-semibold bg-gradient-to-r from-blue-600 to-pink-800 bg-clip-text text-transparent">
Dental U-Care
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
{data.documents.length > 0 && <NavDocuments items={data.documents} />}
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
{user && <NavUser user={user} isAdmin={isAdmin} />}
</SidebarFooter>
</Sidebar>
</>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useEffect } from "react";
import { authClient } from "@/lib/auth-session/auth-client";
import { useRouter } from "next/navigation";
/**
* Client-side redirect for authenticated users visiting auth pages
* This runs after page load to avoid SSR/client hydration conflicts
*/
export function AuthLayoutRedirect() {
const router = useRouter();
useEffect(() => {
// Check session on client side only
const checkSession = async () => {
const { data: session, error } = await authClient.getSession();
if (!error && session?.user) {
const user = session.user as { role?: string };
const role = user.role;
// Redirect based on role
if (role === "admin") {
router.replace("/admin");
} else if (role === "dentist") {
router.replace("/dentist");
} else if (role === "patient") {
router.replace("/patient");
}
}
};
checkSession();
}, [router]);
return null; // This component doesn't render anything
}

View File

@@ -0,0 +1,51 @@
import { AppSidebar } from "@/components/layout/app-sidebar";
import { SiteHeader } from "@/components/layout/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { ReactNode } from "react";
type UserRole = "admin" | "dentist" | "patient";
interface DashboardLayoutProps {
user: {
id: string;
name: string;
email: string;
role: string | null | undefined;
image?: string | null;
};
role: UserRole;
children: ReactNode;
variant?: "inset" | "sidebar";
}
/**
* Shared dashboard layout component that wraps pages with SidebarProvider,
* AppSidebar, and SiteHeader to reduce code duplication across pages.
*/
export function DashboardLayout({
user,
role,
children,
variant = "inset",
}: DashboardLayoutProps) {
return (
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 72)",
"--header-height": "calc(var(--spacing) * 12)",
} as React.CSSProperties
}
>
<AppSidebar variant={variant} user={user} />
<SidebarInset>
<SiteHeader role={role} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
{children}
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,93 @@
"use client"
import {
IconDots,
IconFolder,
IconShare3,
IconTrash,
type Icon,
} from "@tabler/icons-react"
import Link from "next/link"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
export function NavDocuments({
items,
}: {
items: {
name: string
url: string
icon: Icon
}[]
}) {
const { isMobile } = useSidebar()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<Link href={item.url}>
<item.icon />
<span>{item.name}</span>
</Link>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="data-[state=open]:bg-accent rounded-sm"
>
<IconDots />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<IconFolder />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<IconShare3 />
<span>Share</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<IconTrash />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<IconDots className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,41 @@
"use client";
import { type Icon } from "@tabler/icons-react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import Link from "next/link";
export function NavMain({
items,
}: {
items: {
title: string;
url: string;
icon?: Icon;
}[];
}) {
return (
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton tooltip={item.title} asChild>
<Link href={item.url}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -0,0 +1,43 @@
"use client"
import * as React from "react"
import { type Icon } from "@tabler/icons-react"
import Link from "next/link"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function NavSecondary({
items,
...props
}: {
items: {
title: string
url: string
icon: Icon
}[]
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<Link href={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,131 @@
"use client";
import {
IconCreditCard,
IconDotsVertical,
IconLogout,
} from "@tabler/icons-react";
import Link from "next/link";
import { authClient } from "@/lib/auth-session/auth-client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
export function NavUser({
user,
isAdmin,
}: {
user: {
name: string;
email: string;
image?: string | null;
role?: string | null;
};
isAdmin?: boolean;
}) {
const { isMobile } = useSidebar();
const router = useRouter();
const handleSignOut = async () => {
await authClient.signOut();
toast.success("Signed out successfully");
router.push("/sign-in");
};
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg ">
<AvatarImage src={user.image ?? undefined} alt={user.name} />
<AvatarFallback className="rounded-lg">
{user.name?.[0] ?? "U"}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">
{user.email}
</span>
</div>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.image ?? undefined} alt={user.name} />
<AvatarFallback className="rounded-lg">
{user.name?.[0] ?? "U"}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">
{user.email}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{isAdmin && (
<>
<DropdownMenuItem asChild>
<Link href="/admin" className="cursor-pointer">
<IconCreditCard className="mr-2" />
<span>Dashboard</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{user?.role !== "admin" && (
<>
<DropdownMenuItem asChild>
<Link href="/profile" className="cursor-pointer">
<IconCreditCard className="mr-2" />
<span>Profile</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuGroup />
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut}>
<IconLogout />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@@ -0,0 +1,117 @@
import { IconTrendingUp, IconTrendingDown } from "@tabler/icons-react"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
type DashboardStats = {
totalAppointments: number
appointmentChange: number
newPatients: number
patientChange: number
revenue: number
revenueChange: number
satisfactionRate: number
satisfactionChange: number
}
export function SectionCards({ stats }: { stats: DashboardStats }) {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
<Card className="@container/card">
<CardHeader>
<CardDescription>Total Appointments</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{stats.totalAppointments.toLocaleString()}
</CardTitle>
<CardAction>
<Badge variant="outline">
{stats.appointmentChange >= 0 ? <IconTrendingUp /> : <IconTrendingDown />}
{stats.appointmentChange >= 0 ? '+' : ''}{stats.appointmentChange.toFixed(1)}%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
{stats.appointmentChange >= 0 ? 'Bookings up this month' : 'Bookings down this month'}{' '}
{stats.appointmentChange >= 0 ? <IconTrendingUp className="size-4" /> : <IconTrendingDown className="size-4" />}
</div>
<div className="text-muted-foreground">
Appointments for the last 30 days
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>New Patients</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{stats.newPatients.toLocaleString()}
</CardTitle>
<CardAction>
<Badge variant="outline">
{stats.patientChange >= 0 ? <IconTrendingUp /> : <IconTrendingDown />}
{stats.patientChange >= 0 ? '+' : ''}{stats.patientChange.toFixed(1)}%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
{stats.patientChange >= 0 ? 'Growing patient base' : 'Patient growth slowing'}{' '}
{stats.patientChange >= 0 ? <IconTrendingUp className="size-4" /> : <IconTrendingDown className="size-4" />}
</div>
<div className="text-muted-foreground">
New registrations this month
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Revenue This Month</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{stats.revenue.toLocaleString('en-PH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</CardTitle>
<CardAction>
<Badge variant="outline">
{stats.revenueChange >= 0 ? <IconTrendingUp /> : <IconTrendingDown />}
{stats.revenueChange >= 0 ? '+' : ''}{stats.revenueChange.toFixed(1)}%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
{stats.revenueChange >= 0 ? 'Strong financial performance' : 'Revenue needs attention'}{' '}
{stats.revenueChange >= 0 ? <IconTrendingUp className="size-4" /> : <IconTrendingDown className="size-4" />}
</div>
<div className="text-muted-foreground">Total revenue collected</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Patient Satisfaction</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{stats.satisfactionRate.toFixed(1)}%
</CardTitle>
<CardAction>
<Badge variant="outline">
{stats.satisfactionChange >= 0 ? <IconTrendingUp /> : <IconTrendingDown />}
{stats.satisfactionChange >= 0 ? '+' : ''}{stats.satisfactionChange.toFixed(1)}%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
{stats.satisfactionRate >= 95 ? 'Excellent patient feedback' : 'Good patient feedback'}{' '}
{stats.satisfactionChange >= 0 ? <IconTrendingUp className="size-4" /> : <IconTrendingDown className="size-4" />}
</div>
<div className="text-muted-foreground">Based on completed appointments</div>
</CardFooter>
</Card>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { Separator } from "@/components/ui/separator"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { ModeToggle } from "../ui/mode-toggle"
type SiteHeaderProps = {
role?: string | null
}
export function SiteHeader({ role }: SiteHeaderProps = {}) {
const getTitle = () => {
switch (role) {
case 'admin':
return 'Dental U-Care Admin Dashboard'
case 'dentist':
return 'Dental U-Care Dentist Portal'
case 'patient':
return 'Dental U-Care Patient Portal'
default:
return 'Dental U-Care'
}
}
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
<h1 className="text-base font-medium">{getTitle()}</h1>
<div className="ml-auto flex items-center gap-2">
<ModeToggle />
</div>
</div>
</header>
)
}