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,6 @@
---
alwaysApply: true
---
Be very detailed with summarization and do not miss out things that are important.
Important: try to fix things at the cause, not the symptom.

View File

@@ -0,0 +1,37 @@
---
alwaysApply: true
---
You are a Senior Front-End Developer and an Expert in ReactJS, NextJS, JavaScript, TypeScript, HTML, CSS and modern UI/UX frameworks (e.g., TailwindCSS, Shadcn, Radix). You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning.
- Follow the users requirements carefully & to the letter.
- First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail.
- Confirm, then write code!
- Always write correct, best practice, DRY principle (Dont Repeat Yourself), bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines .
- Focus on easy and readability code, over being performant.
- Fully implement all requested functionality.
- Leave NO todos, placeholders or missing pieces.
- Ensure code is complete! Verify thoroughly finalised.
- Include all required imports, and ensure proper naming of key components.
- Be concise Minimize any other prose.
- If you think there might not be a correct answer, you say so.
- If you do not know the answer, say so, instead of guessing.
### Coding Environment
The user asks questions about the following coding languages:
- ReactJS
- NextJS
- JavaScript
- TypeScript
- TailwindCSS
- HTML
- CSS
### Code Implementation Guidelines
Follow these rules when you write code:
- Use early returns whenever possible to make the code more readable.
- Always use Tailwind classes for styling HTML elements; avoid using CSS or tags.
- Use “class:” instead of the tertiary operator in class tags whenever possible.
- Use descriptive variable and function/const names. Also, event functions should be named with a “handle” prefix, like “handleClick” for onClick and “handleKeyDown” for onKeyDown.
- Implement accessibility features on elements. For example, a tag should have a tabindex=“0”, aria-label, on:click, and on:keydown, and similar attributes.
- Use consts instead of functions, for example, “const toggle = () =>”. Also, define a type if possible.

35
.env.example Normal file
View File

@@ -0,0 +1,35 @@
# App Configuration
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Better Auth
BETTER_AUTH_SECRET=your_secret_key_here
BETTER_AUTH_URL=http://localhost:3000
# Database - Prisma Accelerate (optimized for Prisma ORM)
DATABASE_URL="prisma+postgres://accelerate.prisma-data.net/?api_key=your_api_key_here"
# Direct Database Connection (for migrations and Prisma Studio)
DIRECT_URL="postgresql://user:password@host:5432/database?sslmode=require"
# Resend API Key (for email sending)
# Get from: https://resend.com/api-keys
RESEND_API_KEY=your_resend_api_key_here
# Google OAuth Configuration
# Get these from: https://console.cloud.google.com/apis/dashboard
GOOGLE_CLIENT_ID=your_google_client_id_here
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
# For local development, set redirect URI to:
# http://localhost:3000/api/auth/callback/google
# For production, set redirect URI to:
# https://yourdomain.com/api/auth/callback/google
# Stripe Configuration
# Get these from: https://dashboard.stripe.com/apikeys
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# Stripe Publishable Key (for client-side usage)
NEXT_STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key_here

45
.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env
.env.local
# typescript
*.tsbuildinfo
next-env.d.ts
# prisma
/prisma/*.db
/prisma/*.db-journal
/prisma/migrations

27
.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,27 @@
{
"servers": {
"my-mcp-server-f475f1de": {
"url": "https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp",
"type": "http"
},
"my-mcp-server-5e3fe95b": {
"type": "stdio",
"command": "npx",
"args": [
"shadcn@latest",
"mcp",
"init",
"--client",
"vscode"
]
},
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
}
},
"inputs": []
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-session/auth-client";
import { toast } from "sonner";
export function ForgotPasswordForm({
className,
...props
}: React.ComponentProps<"div">) {
const [email, setEmail] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const { error } = await authClient.forgetPassword({
email,
redirectTo: `${window.location.origin}/reset-password`,
});
if (error) {
toast.error("Failed to send reset link", {
description: error.message || "Please try again.",
});
} else {
toast.success("Password reset link sent!", {
description: "Please check your email inbox.",
});
setEmail(""); // Clear the form on success
}
} catch {
toast.error("An unexpected error occurred", {
description: "Please try again later.",
});
} finally {
setIsLoading(false);
}
};
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Forgot Password</CardTitle>
<CardDescription>
Enter your email address and we&apos;ll send you a link to reset
your password
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="m@gmail.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
required
/>
</Field>
<Field>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Sending..." : "Send Reset Link"}
</Button>
<FieldDescription className="text-center">
Remember your password?{" "}
<a
href="/sign-in"
className="underline-offset-4 hover:underline"
>
Sign in
</a>
</FieldDescription>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import Image from "next/image";
import Link from "next/link";
import { ForgotPasswordForm } from "@/app/(auth)/forgot-password/forgot-password-form";
export default function ForgotPasswordPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<Link href="/" className="flex items-center gap-2 self-center font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md p-1">
<Image
width={24}
height={24}
src={"/tooth.svg"}
alt="Dental U Care Logo"
priority
/>
</div>
<span>Dental U Care</span>
</Link>
<ForgotPasswordForm />
</div>
</div>
);
}

13
app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { ReactNode } from "react";
export default async function AuthLayout({
children,
}: {
children: ReactNode;
}) {
// Removed redundant client-side redirect here.
// The sign-in form performs role-based redirects after login
// and middleware protects authenticated routes. Keeping the
// layout minimal avoids redirect race conditions.
return <>{children}</>;
}

View File

@@ -0,0 +1,32 @@
import Image from "next/image";
import Link from "next/link";
import { Suspense } from "react";
import { ResetPasswordForm } from "./reset-password-form";
export default function ForgotPasswordPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<Link
href="/"
className="flex items-center gap-2 self-center font-medium"
>
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md p-1">
<Image
width={24}
height={24}
src={"/tooth.svg"}
alt="Dental U Care Logo"
priority
/>
</div>
<span>Dental U Care</span>
</Link>
<Suspense fallback={<div className="text-center">Loading...</div>}>
<ResetPasswordForm />
</Suspense>
</div>
</div>
);
}

View File

@@ -0,0 +1,254 @@
"use client";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { toast } from "sonner";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import Link from "next/link";
import { authClient } from "@/lib/auth-session/auth-client";
import { useRouter, useSearchParams } from "next/navigation";
export function ResetPasswordForm({
className,
...props
}: React.ComponentProps<"div">) {
const searchParams = useSearchParams();
const router = useRouter();
const token = searchParams.get("token");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [isLoading, setIsLoading] = useState(false);
function togglePassword(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
setShowPassword((s) => !s);
}
function toggleConfirm(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
setShowConfirm((s) => !s);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsLoading(true);
if (!token) {
toast.error("Invalid or missing reset token");
setIsLoading(false);
return;
}
if (password.length < 8) {
toast.error("Password must be at least 8 characters");
setIsLoading(false);
return;
}
if (password !== confirmPassword) {
toast.error("Passwords do not match");
setIsLoading(false);
return;
}
const { error } = await authClient.resetPassword({
newPassword: password,
token,
});
if (error) {
toast.error(error.message || "Failed to reset password");
} else {
toast.success("Password reset successfully");
router.push("/sign-in");
}
setIsLoading(false);
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Reset Password</CardTitle>
<CardDescription>Enter your new password</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your new password"
disabled={isLoading}
minLength={8}
required
/>
<button
aria-label={
showPassword ? "Hide password" : "Show password"
}
onClick={togglePassword}
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 text-sm opacity-70 hover:opacity-100"
>
{showPassword ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.97 9.97 0 012.175-5.875M6.343 6.343A9.97 9.97 0 0112 5c5.523 0 10 4.477 10 10 0 1.042-.161 2.045-.463 2.998M3 3l18 18"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>
</div>
<p className="text-xs text-muted-foreground mt-1">
Must be at least 8 characters
</p>
</Field>
<Field>
<FieldLabel htmlFor="confirmPassword">
Confirm Password
</FieldLabel>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirm ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your new password"
disabled={isLoading}
minLength={8}
required
/>
<button
aria-label={
showConfirm
? "Hide confirm password"
: "Show confirm password"
}
onClick={toggleConfirm}
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 text-sm opacity-70 hover:opacity-100"
>
{showConfirm ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.97 9.97 0 012.175-5.875M6.343 6.343A9.97 9.97 0 0112 5c5.523 0 10 4.477 10 10 0 1.042-.161 2.045-.463 2.998M3 3l18 18"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>
</div>
</Field>
<Field>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Resetting...
</>
) : (
"Reset Password"
)}
</Button>
</Field>
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<Link href="/sign-up" className="underline underline-offset-4">
Sign up
</Link>
</div>
</FieldGroup>
</form>
</CardContent>
</Card>
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
By clicking continue, you agree to our{" "}
<Link href="/docs/terms-of-service">Terms of Service</Link> and{" "}
<Link href="/docs/privacy-policy">Privacy Policy</Link>.
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import Image from "next/image"
import { Metadata } from "next"
import { LoginForm } from "@/app/(auth)/sign-in/sign-in-form"
import Link from "next/link";
export const metadata: Metadata = {
title: "Sign in",
};
export default function LoginPage() {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col p-6 md:p-10">
<div className="flex justify-center md:justify-start flex-shrink-0 mb-8">
<Link href="/" className="flex items-center gap-2 font-semibold text-lg">
<div className=" text-primary-foreground flex size-10 items-center justify-center rounded-lg p-2">
<Image src="/tooth.svg" alt="Dental U Care" width={24} height={24} />
</div>
Dental U Care
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-xs">
<LoginForm />
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block">
<Image
src="/dentist.jpg"
alt="Dental clinic interior"
fill
className="object-cover dark:brightness-80"
priority
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,335 @@
"use client";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import Link from "next/link";
import { useState } from "react";
import { authClient } from "@/lib/auth-session/auth-client";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
export function LoginForm({
className,
...props
}: React.ComponentProps<"form">) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [showVerifyNotice, setShowVerifyNotice] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false);
function togglePassword(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
setShowPassword((s) => !s);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsLoading(true);
try {
await authClient.signIn.email(
{
email,
password,
},
{
onSuccess: async (ctx) => {
// User data is available in ctx.data
const user = ctx.data?.user;
const role = user?.role;
setShowVerifyNotice(false);
// Determine target based on role
const target =
role === "admin"
? "/admin"
: role === "dentist"
? "/dentist"
: role === "patient"
? "/patient"
: "/";
const description =
role === "admin"
? "Redirecting to admin panel..."
: role === "dentist"
? "Redirecting to dentist portal..."
: role === "patient"
? "Redirecting to patient portal..."
: "Welcome back!";
toast.success("Login successful!", { description });
// Small delay to ensure cookie is set before redirect
// This is critical for production where cookie propagation takes time
await new Promise((resolve) => setTimeout(resolve, 500));
// Use window.location.href for full page reload
window.location.href = target;
},
onError: (ctx) => {
if (ctx.error.status === 403) {
setShowVerifyNotice(true);
toast.error("Please verify your email address", {
description: "Check your inbox for the verification link.",
});
} else {
setShowVerifyNotice(false);
toast.error("Login failed", {
description: ctx.error.message || "Invalid email or password.",
});
}
setIsLoading(false);
},
}
);
} catch {
toast.error("An unexpected error occurred", {
description: "Please try again later.",
});
setIsLoading(false);
}
}
async function handleResendVerification(
e: React.MouseEvent<HTMLButtonElement>
) {
e.preventDefault();
setResendLoading(true);
setResendSuccess(false);
try {
const res = await fetch("/api/auth/resend-verification", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (res.ok) {
setResendSuccess(true);
toast.success("Verification email sent!", {
description: "Check your inbox for the verification link.",
});
} else {
toast.error("Failed to resend verification email.");
}
} catch {
toast.error("Failed to resend verification email.");
} finally {
setResendLoading(false);
}
}
async function handleGoogleSignIn() {
try {
setIsGoogleLoading(true);
// Google OAuth redirects to Google, then back to /api/auth/callback/google
// Better Auth handles the callback and creates/updates the session
// The onAfterSignUp hook in auth.ts ensures new users get the "patient" role
// After callback, users are redirected to root "/"
// The auth layout then redirects to role-specific dashboard
await authClient.signIn.social({
provider: "google",
});
// This will cause a redirect, so code after this won't execute
} catch (error) {
console.error("Google sign-in failed:", error);
toast.error("Google sign-in failed", {
description: "Please try again.",
});
setIsGoogleLoading(false);
}
}
return (
<form
className={cn("flex flex-col gap-6", className)}
{...props}
onSubmit={handleSubmit}
>
<FieldGroup>
{showVerifyNotice && (
<div className="bg-yellow-100 border border-yellow-300 text-yellow-800 rounded p-3 text-center mb-2">
<div className="mb-2">
Your email is not verified. Please check your inbox for the
verification link.
</div>
<button
type="button"
className="underline text-sm text-blue-700 disabled:opacity-60"
onClick={handleResendVerification}
disabled={resendLoading || resendSuccess}
>
{resendLoading
? "Resending..."
: resendSuccess
? "Verification Sent!"
: "Resend Verification Email"}
</button>
</div>
)}
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-bold">Login to your account</h1>
<p className="text-muted-foreground text-sm text-balance">
Enter your email below to login to your account
</p>
</div>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="e.g m@gmail.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
required
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password">Password</FieldLabel>
<Link
href="/forgot-password"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</Link>
</div>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
required
/>
<button
aria-label={showPassword ? "Hide password" : "Show password"}
onClick={togglePassword}
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 text-sm opacity-70 hover:opacity-100"
>
{showPassword ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.97 9.97 0 012.175-5.875M6.343 6.343A9.97 9.97 0 0112 5c5.523 0 10 4.477 10 10 0 1.042-.161 2.045-.463 2.998M3 3l18 18"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>
</div>
</Field>
<Field>
<Button type="submit" disabled={isLoading || isGoogleLoading}>
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
{"Logging in..."}
</>
) : (
"Login"
)}
</Button>
</Field>
<FieldSeparator>Or continue with</FieldSeparator>
<Field>
<Button
variant="outline"
type="button"
onClick={handleGoogleSignIn}
disabled={isLoading || isGoogleLoading}
>
{isGoogleLoading ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
{"Signing in..."}
</>
) : (
<>
<svg
width="800px"
height="800px"
viewBox="-3 0 262 262"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
className="size-4 mr-2"
>
<path
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
fill="#4285F4"
/>
<path
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
fill="#34A853"
/>
<path
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
fill="#FBBC05"
/>
<path
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
fill="#EB4335"
/>
</svg>
Login with Google
</>
)}
</Button>
<FieldDescription className="text-center">
Don&apos;t have an account?{" "}
<Link href="/sign-up" className="underline underline-offset-4">
Sign up
</Link>
</FieldDescription>
</Field>
</FieldGroup>
</form>
);
}

View File

@@ -0,0 +1,46 @@
import { Metadata } from "next";
import { SignupForm } from "@/app/(auth)/sign-up/sign-up-form";
import Image from "next/image";
import Link from "next/link";
export const metadata: Metadata = {
title: "Sign Up",
};
export default function SignupPage() {
return (
<div className="grid h-screen lg:grid-cols-2">
<div className="flex flex-col p-4 md:p-6 h-screen overflow-hidden">
<div className="flex justify-center md:justify-start flex-shrink-0 mb-8">
<Link
href="/"
className="flex items-center gap-2 font-semibold text-lg"
>
<div className=" text-primary-foreground flex size-10 items-center justify-center rounded-lg p-2">
<Image
src="/tooth.svg"
alt="Dental U Care"
width={24}
height={24}
/>
</div>
Dental U Care
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-xs">
<SignupForm />
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block h-screen">
<Image
src="/doctor-image.jpg"
alt="Doctor"
className="absolute inset-0 h-full w-full object-cover dark:brightness-80"
priority
fill
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,414 @@
"use client";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import Link from "next/link";
import { useState } from "react";
import { authClient } from "@/lib/auth-session/auth-client";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
export function SignupForm({
className,
...props
}: React.ComponentProps<"form">) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [showVerifyNotice, setShowVerifyNotice] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false);
function togglePassword(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
setShowPassword((s) => !s);
}
function toggleConfirm(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
setShowConfirm((s) => !s);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsLoading(true);
// Validate passwords match
if (password !== confirmPassword) {
toast.error("Passwords don't match", {
description: "Please make sure both passwords are the same.",
});
setIsLoading(false);
return;
}
if (!/[^A-Za-z0-9]/.test(password)) {
toast.error("Password must contain at least one special character", {
description:
"Please include at least one special character in your password.",
});
setIsLoading(false);
return;
}
// Validate password length
if (password.length < 8) {
toast.error("Password too short", {
description: "Password must be at least 8 characters long.",
});
setIsLoading(false);
return;
}
try {
const { error } = await authClient.signUp.email({
email,
password,
name,
});
if (error) {
if (error.status === 403) {
setShowVerifyNotice(true);
toast.error("Please verify your email address", {
description: "Check your inbox for the verification link.",
});
} else {
setShowVerifyNotice(false);
toast.error("Sign up failed", {
description:
error.message || "Unable to create account. Please try again.",
});
}
} else {
setShowVerifyNotice(false);
toast.success("Account created successfully!", {
description: "Please check your email to verify your account.",
});
// Redirect to login page after successful signup using full page reload
setTimeout(() => {
window.location.href = "/sign-in";
}, 2000);
}
} catch {
toast.error("An unexpected error occurred", {
description: "Please try again later.",
});
} finally {
setIsLoading(false);
}
}
async function handleResendVerification(
e: React.MouseEvent<HTMLButtonElement>
) {
e.preventDefault();
setResendLoading(true);
setResendSuccess(false);
try {
const res = await fetch("/api/auth/resend-verification", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (res.ok) {
setResendSuccess(true);
toast.success("Verification email sent!", {
description: "Check your inbox for the verification link.",
});
} else {
toast.error("Failed to resend verification email.");
}
} catch {
toast.error("Failed to resend verification email.");
} finally {
setResendLoading(false);
}
}
async function handleGoogleSignUp() {
try {
setIsGoogleLoading(true);
// Google OAuth will redirect to Google, then back to callback
// The onAfterSignUp hook in auth.ts assigns "patient" role to new users
// After callback, the auth layout redirects to the role-specific dashboard
await authClient.signIn.social({
provider: "google",
});
} catch (error) {
console.error("Google sign-up failed:", error);
toast.error("Google sign-up failed", {
description: "Please try again.",
});
setIsGoogleLoading(false);
}
}
return (
<form
className={cn("flex flex-col gap-3", className)}
{...props}
onSubmit={handleSubmit}
>
{showVerifyNotice && (
<div className="bg-yellow-100 border border-yellow-300 text-yellow-800 rounded p-3 text-center mb-2">
<div className="mb-2">
Your email is not verified. Please check your inbox for the
verification link.
</div>
<button
type="button"
className="underline text-sm text-blue-700 disabled:opacity-60"
onClick={handleResendVerification}
disabled={resendLoading || resendSuccess}
>
{resendLoading
? "Resending..."
: resendSuccess
? "Verification Sent!"
: "Resend Verification Email"}
</button>
</div>
)}
<FieldGroup className="gap-3">
<div className="flex flex-col items-center text-center mb-2">
<h1 className="text-2xl font-bold">Create your account</h1>
<p className="text-muted-foreground text-sm">
Fill in the form below to create your account
</p>
</div>
<Field className="gap-1">
<FieldLabel htmlFor="name" className="text-xs">
Full Name
</FieldLabel>
<Input
id="name"
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isLoading}
required
/>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="email" className="text-xs">
Email
</FieldLabel>
<Input
id="email"
type="email"
placeholder="e.g m@gmail.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
required
className="h-9"
/>
<FieldDescription className="text-xs leading-tight">
We&apos;ll use this to contact you.
</FieldDescription>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="password" className="text-xs">
Password
</FieldLabel>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
minLength={8}
required
className="h-9"
/>
<button
aria-label={showPassword ? "Hide password" : "Show password"}
onClick={togglePassword}
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 text-sm opacity-70 hover:opacity-100"
>
{showPassword ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.97 9.97 0 012.175-5.875M6.343 6.343A9.97 9.97 0 0112 5c5.523 0 10 4.477 10 10 0 1.042-.161 2.045-.463 2.998M3 3l18 18"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>
</div>
<FieldDescription className="text-xs leading-tight">
Must be at least 8 characters long.
</FieldDescription>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="confirm-password" className="text-xs">
Confirm Password
</FieldLabel>
<div className="relative">
<Input
id="confirm-password"
type={showConfirm ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
minLength={8}
required
className="h-9"
/>
<button
aria-label={
showConfirm ? "Hide confirm password" : "Show confirm password"
}
onClick={toggleConfirm}
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 text-sm opacity-70 hover:opacity-100"
>
{showConfirm ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.97 9.97 0 012.175-5.875M6.343 6.343A9.97 9.97 0 0112 5c5.523 0 10 4.477 10 10 0 1.042-.161 2.045-.463 2.998M3 3l18 18"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>
</div>
<FieldDescription className="text-xs leading-tight">
Please confirm your password.
</FieldDescription>
</Field>
<Field className="gap-1">
<Button
type="submit"
disabled={isLoading || isGoogleLoading}
className="h-9"
>
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Creating account...
</>
) : (
"Create Account"
)}
</Button>
</Field>
<FieldSeparator>Or continue with</FieldSeparator>
<Field>
<Button
variant="outline"
type="button"
onClick={handleGoogleSignUp}
disabled={isLoading || isGoogleLoading}
>
<svg
width="800px"
height="800px"
viewBox="-3 0 262 262"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
>
<path
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
fill="#4285F4"
/>
<path
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
fill="#34A853"
/>
<path
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
fill="#FBBC05"
/>
<path
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
fill="#EB4335"
/>
</svg>
{isGoogleLoading ? "Signing up..." : "Sign up with Google"}
</Button>
<FieldDescription className="px-6 text-center">
Already have an account? <Link href="/sign-in">Sign in</Link>
</FieldDescription>
</Field>
</FieldGroup>
</form>
);
}

View File

@@ -0,0 +1,18 @@
"use server";
import { getServerSession } from "@/lib/auth-session/get-session";
import { forbidden, unauthorized } from "next/navigation";
import { setTimeout } from "node:timers/promises";
export async function deleteApplication() {
const session = await getServerSession();
const user = session?.user;
if (!user) unauthorized();
if (user.role !== "admin") forbidden();
// Delete app...
await setTimeout(800);
}

View File

@@ -0,0 +1,49 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { AdminAppointmentsTable } from "@/components/admin/appointments-table";
import { requireAdmin } from "@/lib/auth-session/auth-server";
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Appointment Management",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function AppointmentManagementPage() {
const { user } = await requireAdmin();
// Add pagination limit to prevent loading too much data at once
// Use safe find to filter out orphaned appointments
const appointments = await safeFindManyAppointments({
take: 100, // Limit to 100 most recent appointments
include: {
patient: true,
dentist: true,
service: true,
payment: true,
},
orderBy: {
date: "desc",
},
});
return (
<DashboardLayout
user={{ ...user, role: user.role || "admin" }}
role="admin"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Appointment Management</h1>
<p className="text-muted-foreground">
Manage all appointments in the system
</p>
</div>
<AdminAppointmentsTable appointments={appointments} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,62 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { AdminDentistsTable } from "@/components/admin/dentists-table";
import { requireAdmin } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Dentist Management",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function DentistManagementPage() {
const { user } = await requireAdmin();
const dentistsData = await prisma.user.findMany({
take: 50, // Limit to 50 dentists to prevent excessive data loading
where: {
role: "dentist",
},
include: {
appointmentsAsDentist: {
take: 10, // Limit appointments per dentist to avoid N+1 issue
include: {
service: true,
patient: true,
},
orderBy: {
date: "desc",
},
},
},
orderBy: {
createdAt: "desc",
},
});
// Transform the data to match the expected Dentist type
const dentists = dentistsData.map((dentist) => ({
...dentist,
experience: dentist.experience !== null ? String(dentist.experience) : null,
}));
return (
<DashboardLayout
user={{ ...user, role: user.role || "admin" }}
role="admin"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Dentist Management</h1>
<p className="text-muted-foreground">
Manage all dentists in the system
</p>
</div>
<AdminDentistsTable dentists={dentists} />
</div>
</DashboardLayout>
);
}

197
app/(main)/admin/page.tsx Normal file
View File

@@ -0,0 +1,197 @@
import { ChartAreaInteractive } from "@/components/chart/chart-area-interactive";
import { AdminAppointmentsTable } from "@/components/admin/appointments-table";
import { SectionCards } from "@/components/layout/section-cards";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import type { Metadata } from "next";
import { requireAdmin } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
export const metadata: Metadata = {
title: "Dashboard",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function Page() {
// Require admin role - will redirect to home page (/) if not admin
const { user } = await requireAdmin();
// Calculate date ranges once for reuse
const now = new Date();
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const thirtyDaysAgo = new Date(now.getTime() - 30 * DAY_IN_MS);
const sixtyDaysAgo = new Date(now.getTime() - 60 * DAY_IN_MS);
const ninetyDaysAgo = new Date(now.getTime() - 90 * DAY_IN_MS);
// Run all count queries in parallel for better performance
const [
totalAppointments,
previousAppointments,
newPatients,
previousPatients,
payments,
previousPayments,
completedAppointments,
previousCompleted,
appointmentsForChart,
appointments,
] = await Promise.all([
// Total appointments in last 30 days
prisma.appointment.count({
where: {
createdAt: { gte: thirtyDaysAgo },
},
}),
// Previous period appointments for comparison
prisma.appointment.count({
where: {
createdAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
},
}),
// New patients in last 30 days
prisma.user.count({
where: {
role: "patient",
createdAt: { gte: thirtyDaysAgo },
},
}),
// Previous period patients
prisma.user.count({
where: {
role: "patient",
createdAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
},
}),
// Revenue this month
prisma.payment.aggregate({
where: {
status: "paid",
paidAt: { gte: thirtyDaysAgo },
},
_sum: {
amount: true,
},
}),
// Previous period revenue
prisma.payment.aggregate({
where: {
status: "paid",
paidAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
},
_sum: {
amount: true,
},
}),
// Calculate completed appointments for satisfaction (mock calculation)
prisma.appointment.count({
where: {
status: "completed",
updatedAt: { gte: thirtyDaysAgo },
},
}),
// Previous completed
prisma.appointment.count({
where: {
status: "completed",
updatedAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
},
}),
// Fetch appointments for chart (last 90 days)
prisma.appointment.findMany({
where: {
createdAt: { gte: ninetyDaysAgo },
},
select: {
createdAt: true,
},
orderBy: {
createdAt: "asc",
},
}),
// Fetch recent appointments for table
// Use safe find to filter out orphaned appointments
safeFindManyAppointments({
take: 20,
orderBy: {
createdAt: "desc",
},
include: {
patient: true,
dentist: true,
service: true,
payment: true,
},
}),
]);
const revenue = payments._sum.amount || 0;
const previousRevenue = previousPayments._sum.amount || 0;
// Calculate percentage changes
const appointmentChange =
previousAppointments > 0
? ((totalAppointments - previousAppointments) / previousAppointments) *
100
: 0;
const patientChange =
previousPatients > 0
? ((newPatients - previousPatients) / previousPatients) * 100
: 0;
const revenueChange =
previousRevenue > 0
? ((revenue - previousRevenue) / previousRevenue) * 100
: 0;
const satisfactionChange =
previousCompleted > 0
? ((completedAppointments - previousCompleted) / previousCompleted) * 100
: 0;
// Mock satisfaction rate (in a real app, this would come from reviews/ratings)
const satisfactionRate = 98.5;
// Group appointments by date for chart
const chartData = appointmentsForChart.reduce(
(acc: Record<string, number>, appointment) => {
const date = appointment.createdAt.toISOString().split("T")[0];
acc[date] = (acc[date] || 0) + 1;
return acc;
},
{}
);
// Convert to array format for chart
const chartDataArray = Object.entries(chartData).map(([date, count]) => ({
date,
appointments: count,
}));
const dashboardStats = {
totalAppointments,
appointmentChange,
newPatients,
patientChange,
revenue,
revenueChange,
satisfactionRate,
satisfactionChange,
};
return (
<DashboardLayout
user={{ ...user, role: user.role || "admin" }}
role="admin"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<SectionCards stats={dashboardStats} />
<div className="px-4 lg:px-6">
<ChartAreaInteractive data={chartDataArray} />
</div>
<div className="px-4 lg:px-6">
<AdminAppointmentsTable appointments={appointments} />
</div>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,62 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { AdminPatientsTable } from "@/components/admin/patients-table";
import { requireAdmin } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Patient Management",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function PatientManagementPage() {
const { user } = await requireAdmin();
const patients = await prisma.user.findMany({
take: 50, // Limit to 50 patients to prevent excessive data loading
where: {
role: "patient",
},
include: {
appointmentsAsPatient: {
take: 10, // Limit appointments per patient to avoid N+1 issue
include: {
service: true,
dentist: true,
},
orderBy: {
date: "desc",
},
},
payments: {
take: 10, // Limit payments per patient
orderBy: {
createdAt: "desc",
},
},
},
orderBy: {
createdAt: "desc",
},
});
return (
<DashboardLayout
user={{ ...user, role: user.role || "admin" }}
role="admin"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Patient Management</h1>
<p className="text-muted-foreground">
Manage all patients in the system
</p>
</div>
<AdminPatientsTable patients={patients} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,53 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { AdminServicesTable } from "@/components/admin/services-table";
import { requireAdmin } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Service Management",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function ServiceManagementPage() {
const { user } = await requireAdmin();
const servicesData = await prisma.service.findMany({
take: 100, // Limit to 100 services
include: {
appointments: {
take: 5, // Limit appointments per service to avoid N+1 issue
orderBy: {
date: "desc",
},
},
},
orderBy: {
name: "asc",
},
});
// Transform the data to match the expected Service type
const services = servicesData.map((service) => ({
...service,
description: service.description ?? "",
}));
return (
<DashboardLayout
user={{ ...user, role: user.role || "admin" }}
role="admin"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Service Management</h1>
<p className="text-muted-foreground">Manage all dental services</p>
</div>
<AdminServicesTable services={services} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,19 @@
import { requireAdmin } from "@/lib/auth-session/auth-server";
import { AdminSettingsContent } from "@/components/admin/settings-content";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function AdminSettingsPage() {
const { user } = await requireAdmin();
return (
<DashboardLayout
user={{ ...user, role: user.role || "admin" }}
role="admin"
>
<AdminSettingsContent user={{ ...user, role: user.role || "admin" }} />
</DashboardLayout>
);
}

View File

@@ -0,0 +1,42 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { AdminUsersTable } from "@/components/admin/users-table";
import { requireAdmin } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "User Management",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function UserManagementPage() {
const { user } = await requireAdmin();
const usersRaw = await prisma.user.findMany({
take: 100, // Limit to 100 most recent users
orderBy: {
createdAt: "desc",
},
});
const users = usersRaw.map((u) => ({ ...u, role: u.role ?? undefined }));
return (
<DashboardLayout
user={{ ...user, role: user.role || "admin" }}
role="admin"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">User Management</h1>
<p className="text-muted-foreground">
Manage all users in the system
</p>
</div>
<AdminUsersTable users={users} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,57 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { DentistAppointmentsList } from "@/components/dentist/appointments-list";
import { requireDentist } from "@/lib/auth-session/auth-server";
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Appointments - Dentist",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function DentistAppointmentsPage() {
const { user } = await requireDentist();
const appointmentsData = await safeFindManyAppointments({
take: 100, // Limit to 100 most recent appointments
where: {
dentistId: user.id,
},
include: {
patient: true,
service: true,
payment: true,
},
orderBy: {
date: "desc",
},
});
const appointments = appointmentsData.map((appointment) => ({
...appointment,
service: {
...appointment.service,
price: parseFloat(appointment.service.price),
},
}));
return (
<DashboardLayout
user={{ ...user, role: user.role || "dentist" }}
role="dentist"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Appointments</h1>
<p className="text-muted-foreground">
Manage your patient appointments
</p>
</div>
<DentistAppointmentsList appointments={appointments} />
</div>
</DashboardLayout>
);
}

294
app/(main)/dentist/page.tsx Normal file
View File

@@ -0,0 +1,294 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Calendar, Clock, Users, CheckCircle } from "lucide-react";
import Link from "next/link";
import { requireDentist } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Dentist Dashboard",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function DentistDashboard() {
// Require dentist role - will redirect to appropriate page if not dentist
const { user } = await requireDentist();
// Calculate date ranges
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const now = new Date();
// Run all queries in parallel for better performance
const [
todayAppointments,
pendingAppointments,
totalPatients,
completedAppointments,
upcomingAppointments,
] = await Promise.all([
// Fetch today's appointments
safeFindManyAppointments({
where: {
dentistId: user.id,
date: {
gte: today,
lt: tomorrow,
},
status: {
in: ["pending", "confirmed"],
},
},
include: {
patient: true,
service: true,
},
orderBy: {
timeSlot: "asc",
},
}),
// Fetch pending appointments
safeFindManyAppointments({
where: {
dentistId: user.id,
status: "pending",
date: {
gte: now,
},
},
include: {
patient: true,
service: true,
},
orderBy: {
date: "asc",
},
take: 5,
}),
// Get total unique patients
prisma.appointment.groupBy({
by: ["patientId"],
where: {
dentistId: user.id,
},
}),
// Get completed appointments count
prisma.appointment.count({
where: {
dentistId: user.id,
status: "completed",
},
}),
// Get upcoming appointments count
prisma.appointment.count({
where: {
dentistId: user.id,
status: {
in: ["pending", "confirmed"],
},
date: {
gte: now,
},
},
}),
]);
return (
<DashboardLayout
user={{ ...user, role: user.role || "dentist" }}
role="dentist"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Welcome, Dr. {user.name}!</h1>
<p className="text-muted-foreground">
Manage your schedule and patients
</p>
</div>
{/* Statistics Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Today&apos;s Appointments
</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{todayAppointments.length}
</div>
<p className="text-xs text-muted-foreground">
Scheduled for today
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Upcoming</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{upcomingAppointments}</div>
<p className="text-xs text-muted-foreground">
Future appointments
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Patients
</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalPatients.length}</div>
<p className="text-xs text-muted-foreground">Unique patients</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{completedAppointments}</div>
<p className="text-xs text-muted-foreground">All time</p>
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<div className="grid gap-4 md:grid-cols-3">
<Link href="/dentist/appointments">
<Button className="w-full h-20" variant="outline">
<div className="flex flex-col items-center gap-2">
<Calendar className="h-6 w-6" />
<span>View All Appointments</span>
</div>
</Button>
</Link>
<Link href="/dentist/schedule">
<Button className="w-full h-20" variant="outline">
<div className="flex flex-col items-center gap-2">
<Clock className="h-6 w-6" />
<span>Manage Schedule</span>
</div>
</Button>
</Link>
<Link href="/dentist/patients">
<Button className="w-full h-20" variant="outline">
<div className="flex flex-col items-center gap-2">
<Users className="h-6 w-6" />
<span>Patient Records</span>
</div>
</Button>
</Link>
</div>
{/* Today's Schedule */}
<Card>
<CardHeader>
<CardTitle>Today&apos;s Schedule</CardTitle>
<CardDescription>{today.toLocaleDateString()}</CardDescription>
</CardHeader>
<CardContent>
{todayAppointments.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No appointments scheduled for today
</p>
) : (
<div className="space-y-4">
{todayAppointments.map((appointment) => (
<div
key={appointment.id}
className="flex items-center justify-between border-b pb-4 last:border-0"
>
<div>
<p className="font-medium">{appointment.patient.name}</p>
<p className="text-sm text-muted-foreground">
{appointment.service.name}
</p>
<p className="text-sm text-muted-foreground">
{appointment.timeSlot}
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline">
View Details
</Button>
{appointment.status === "pending" && (
<Button size="sm">Confirm</Button>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Pending Appointments */}
<Card>
<CardHeader>
<CardTitle>Pending Appointments</CardTitle>
<CardDescription>
Appointments awaiting confirmation
</CardDescription>
</CardHeader>
<CardContent>
{pendingAppointments.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No pending appointments
</p>
) : (
<div className="space-y-4">
{pendingAppointments.map((appointment) => (
<div
key={appointment.id}
className="flex items-center justify-between border-b pb-4 last:border-0"
>
<div>
<p className="font-medium">{appointment.patient.name}</p>
<p className="text-sm text-muted-foreground">
{appointment.service.name}
</p>
<p className="text-sm text-muted-foreground">
{new Date(appointment.date).toLocaleDateString()} at{" "}
{appointment.timeSlot}
</p>
</div>
<div className="flex gap-2">
<Button size="sm">Accept</Button>
<Button size="sm" variant="destructive">
Decline
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,64 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { DentistPatientsTable } from "@/components/dentist/patients-table";
import { requireDentist } from "@/lib/auth-session/auth-server";
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Patient Records",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function DentistPatientsPage() {
const { user } = await requireDentist();
// Get all unique patients who have appointments with this dentist
// Use safe find to filter out orphaned appointments
const appointments = await safeFindManyAppointments({
take: 200, // Limit to prevent excessive data loading
where: {
dentistId: user.id,
},
include: {
patient: true,
service: true,
},
orderBy: {
date: "desc",
},
});
// Group by patient
const patientsMap = new Map();
appointments.forEach((apt) => {
if (!patientsMap.has(apt.patient.id)) {
patientsMap.set(apt.patient.id, {
...apt.patient,
appointments: [],
});
}
patientsMap.get(apt.patient.id).appointments.push(apt);
});
const patients = Array.from(patientsMap.values());
return (
<DashboardLayout
user={{ ...user, role: user.role || "dentist" }}
role="dentist"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Patient Records</h1>
<p className="text-muted-foreground">
View your patients&apos; information and history
</p>
</div>
<DentistPatientsTable patients={patients} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,49 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { requireDentist } from "@/lib/auth-session/auth-server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Manage Schedule",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function DentistSchedulePage() {
const { user } = await requireDentist();
return (
<DashboardLayout
user={{ ...user, role: user.role || "dentist" }}
role="dentist"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Manage Schedule</h1>
<p className="text-muted-foreground">
Set your working hours and availability
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Working Hours</CardTitle>
<CardDescription>Configure your weekly schedule</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-center py-8">
Schedule management feature coming soon
</p>
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,44 @@
import { requireAuth } from "@/lib/auth-session/auth-server";
import { redirect } from "next/navigation";
import { UserSettingsContent } from "@/components/user/settings-content";
import { prisma } from "@/lib/types/prisma";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function DentistSettingsPage() {
const session = await requireAuth();
if (session.user.role !== "dentist") {
redirect("/forbidden");
}
// Fetch full user data
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
name: true,
email: true,
phone: true,
image: true,
dateOfBirth: true,
address: true,
role: true,
},
});
if (!user) {
redirect("/sign-in");
}
return (
<DashboardLayout
user={{ ...user, role: user.role || "dentist" }}
role="dentist"
>
<UserSettingsContent user={{ ...user, role: user.role || "dentist" }} />
</DashboardLayout>
);
}

View File

@@ -0,0 +1,78 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { AppointmentsList } from "@/components/patient/appointments-list";
import { requirePatient } from "@/lib/auth-session/auth-server";
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
import type { Metadata } from "next";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { CheckCircle } from "lucide-react";
export const metadata: Metadata = {
title: "My Appointments",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
interface AppointmentsPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function AppointmentsPage({
searchParams,
}: AppointmentsPageProps) {
const { user } = await requirePatient();
const appointmentsData = await safeFindManyAppointments({
take: 50, // Limit to 50 most recent appointments
where: {
patientId: user.id,
},
include: {
dentist: true,
service: true,
payment: true,
},
orderBy: {
date: "desc",
},
});
const appointments = appointmentsData.map((appointment) => ({
...appointment,
service: {
...appointment.service,
price: appointment.service.price, // Keep price as is (can be string or number)
},
}));
const params = await searchParams;
const showSuccess = params.success === "true";
return (
<DashboardLayout
user={{ ...user, role: user.role || "patient" }}
role="patient"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">My Appointments</h1>
<p className="text-muted-foreground">
View and manage your dental appointments
</p>
</div>
{showSuccess && (
<Alert className="border-green-200 bg-green-50 dark:bg-green-950/30">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-800 dark:text-green-200">
Your appointment has been successfully booked! Check your email
for confirmation.
</AlertDescription>
</Alert>
)}
<AppointmentsList appointments={appointments} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,93 @@
import type React from "react";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import BookingForm from "@/components/patient/booking-form";
import { requirePatient } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { XCircle } from "lucide-react";
export const metadata: Metadata = {
title: "Book Appointment",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
interface BookAppointmentPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function BookAppointmentPage({
searchParams,
}: BookAppointmentPageProps) {
const { user } = await requirePatient();
// Fetch available services from database
const servicesFromDb = await prisma.service.findMany({
where: {
isActive: true,
},
orderBy: {
name: "asc",
},
});
// Transform services to match component expectations
const services = servicesFromDb.map((service) => ({
id: service.id,
name: service.name,
price: service.price,
duration: service.duration,
category: service.category,
description: service.description || undefined,
}));
// Fetch available dentists
const dentists = await prisma.user.findMany({
where: {
role: "dentist",
isAvailable: true,
},
select: {
id: true,
name: true,
specialization: true,
image: true,
},
orderBy: {
name: "asc",
},
});
// Transform dentists data to match component expectations
const transformedDentists = dentists.map((dentist) => ({
...dentist,
specialization: dentist.specialization || undefined,
image: dentist.image || undefined,
}));
const params = await searchParams;
const showCanceled = params.canceled === "true";
return (
<DashboardLayout
user={{ ...user, role: user.role || "patient" }}
role="patient"
>
{showCanceled && (
<Alert className="m-4 md:m-8 border-red-200 bg-red-50 dark:bg-red-950/30">
<XCircle className="h-4 w-4 text-red-600" />
<AlertDescription className="text-red-800 dark:text-red-200">
Payment was canceled. You can try booking again.
</AlertDescription>
</Alert>
)}
<BookingForm
services={services}
dentists={transformedDentists}
patientId={user.id}
/>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,176 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { requirePatient } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
import { FileText, Calendar, User } from "lucide-react";
export const metadata: Metadata = {
title: "Health Records",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function HealthRecordsPage() {
const { user } = await requirePatient();
const userDetails = await prisma.user.findUnique({
where: { id: user.id },
include: {
appointmentsAsPatient: {
where: {
status: "completed",
},
include: {
service: true,
dentist: true,
},
orderBy: {
date: "desc",
},
},
},
});
return (
<DashboardLayout
user={{ ...user, role: user.role || "patient" }}
role="patient"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Health Records</h1>
<p className="text-muted-foreground">
Your medical history and treatment records
</p>
</div>
{/* Personal Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Personal Information
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm font-medium text-muted-foreground">
Full Name
</p>
<p className="text-base">{userDetails?.name}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Email</p>
<p className="text-base">{userDetails?.email}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Phone</p>
<p className="text-base">
{userDetails?.phone || "Not provided"}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Date of Birth
</p>
<p className="text-base">
{userDetails?.dateOfBirth
? new Date(userDetails.dateOfBirth).toLocaleDateString()
: "Not provided"}
</p>
</div>
<div className="md:col-span-2">
<p className="text-sm font-medium text-muted-foreground">
Address
</p>
<p className="text-base">
{userDetails?.address || "Not provided"}
</p>
</div>
</CardContent>
</Card>
{/* Medical History */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Medical History
</CardTitle>
<CardDescription>
Important medical information for your dentist
</CardDescription>
</CardHeader>
<CardContent>
{userDetails?.medicalHistory ? (
<p className="text-base whitespace-pre-wrap">
{userDetails.medicalHistory}
</p>
) : (
<p className="text-muted-foreground">
No medical history recorded
</p>
)}
</CardContent>
</Card>
{/* Treatment History */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Treatment History
</CardTitle>
<CardDescription>Completed dental procedures</CardDescription>
</CardHeader>
<CardContent>
{!userDetails?.appointmentsAsPatient ||
userDetails.appointmentsAsPatient.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No treatment history
</p>
) : (
<div className="space-y-4">
{userDetails.appointmentsAsPatient.map((appointment) => (
<div
key={appointment.id}
className="border-b pb-4 last:border-0"
>
<div className="flex items-start justify-between">
<div>
<p className="font-medium">
{appointment.service.name}
</p>
<p className="text-sm text-muted-foreground">
Dr. {appointment.dentist.name}
</p>
<p className="text-sm text-muted-foreground">
{new Date(appointment.date).toLocaleDateString()} at{" "}
{appointment.timeSlot}
</p>
{appointment.notes && (
<p className="text-sm mt-2">
<span className="font-medium">Notes:</span>{" "}
{appointment.notes}
</p>
)}
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,92 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { PatientSectionCards } from "@/components/patient/section-cards";
import { requirePatient } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Patient Dashboard",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function PatientDashboard() {
// Require patient role - will redirect to appropriate page if not patient
const { user } = await requirePatient();
const now = new Date();
// Run all queries in parallel for better performance
const [
upcomingAppointmentsCount,
completedAppointmentsCount,
totalSpentResult,
pendingPaymentsResult,
] = await Promise.all([
// Fetch upcoming appointments count
prisma.appointment.count({
where: {
patientId: user.id,
date: {
gte: now,
},
status: {
in: ["pending", "confirmed"],
},
},
}),
// Fetch completed appointments count
prisma.appointment.count({
where: {
patientId: user.id,
status: "completed",
},
}),
// Calculate total spent (paid payments)
prisma.payment.aggregate({
where: {
userId: user.id,
status: "paid",
},
_sum: {
amount: true,
},
}),
// Calculate pending payments
prisma.payment.aggregate({
where: {
userId: user.id,
status: "pending",
},
_sum: {
amount: true,
},
}),
]);
const patientStats = {
upcomingAppointments: upcomingAppointmentsCount,
completedAppointments: completedAppointmentsCount,
totalSpent: totalSpentResult._sum.amount || 0,
pendingPayments: pendingPaymentsResult._sum.amount || 0,
};
return (
<DashboardLayout
user={{ ...user, role: user.role || "patient" }}
role="patient"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="px-4 lg:px-6">
<h1 className="text-3xl font-bold">Welcome back, {user.name}!</h1>
<p className="text-muted-foreground">
Manage your appointments and health records
</p>
</div>
<PatientSectionCards stats={patientStats} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,52 @@
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { PaymentHistory } from "@/components/patient/payment-history";
import { requirePatient } from "@/lib/auth-session/auth-server";
import { prisma } from "@/lib/types/prisma";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Payment History",
};
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function PaymentsPage() {
const { user } = await requirePatient();
const payments = await prisma.payment.findMany({
take: 50, // Limit to 50 most recent payments
where: {
userId: user.id,
},
include: {
appointment: {
include: {
service: true,
dentist: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return (
<DashboardLayout
user={{ ...user, role: user.role || "patient" }}
role="patient"
>
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Payment History</h1>
<p className="text-muted-foreground">
View your payment transactions
</p>
</div>
<PaymentHistory payments={payments} />
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,44 @@
import { requireAuth } from "@/lib/auth-session/auth-server";
import { redirect } from "next/navigation";
import { UserSettingsContent } from "@/components/user/settings-content";
import { prisma } from "@/lib/types/prisma";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
// Force dynamic rendering since this page uses authentication (headers)
export const dynamic = "force-dynamic";
export default async function UserSettingsPage() {
const session = await requireAuth();
if (session.user.role !== "patient") {
redirect("/");
}
// Fetch full user data
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
name: true,
email: true,
phone: true,
image: true,
dateOfBirth: true,
address: true,
role: true,
},
});
if (!user) {
redirect("/sign-in");
}
return (
<DashboardLayout
user={{ ...user, role: user.role || "patient" }}
role="patient"
>
<UserSettingsContent user={{ ...user, role: user.role || "patient" }} />
</DashboardLayout>
);
}

View File

@@ -0,0 +1,9 @@
import {Metadata} from "next"
export const metadata: Metadata = {
title: "Profile",
};
export default function Profile (){
return
}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/types/prisma";
import { auth } from "@/lib/auth-session/auth";
import { Prisma } from "@prisma/client";
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: await import("next/headers").then((mod) => mod.headers()),
});
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only admin can edit appointments
if (session.user.role !== "admin") {
return NextResponse.json(
{ error: "Forbidden: Admin access required" },
{ status: 403 }
);
}
const { id } = await params;
const body = await request.json();
const { date, timeSlot, status, notes } = body;
// Validate that the appointment exists
const existingAppointment = await prisma.appointment.findUnique({
where: { id },
});
if (!existingAppointment) {
return NextResponse.json(
{ error: "Appointment not found" },
{ status: 404 }
);
}
// Build update data object
const updateData: Prisma.AppointmentUpdateInput = {};
if (date !== undefined) updateData.date = new Date(date);
if (timeSlot !== undefined) updateData.timeSlot = timeSlot;
if (status !== undefined) updateData.status = status;
if (notes !== undefined) updateData.notes = notes;
// Update the appointment
const updatedAppointment = await prisma.appointment.update({
where: { id },
data: updateData,
include: {
patient: true,
dentist: true,
service: true,
payment: true,
},
});
return NextResponse.json({
success: true,
appointment: updatedAppointment,
message: "Appointment updated successfully",
});
} catch (error) {
console.error("Error updating appointment:", error);
return NextResponse.json(
{ error: "Failed to update appointment" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/types/prisma";
import { auth } from "@/lib/auth-session/auth";
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { status, cancelReason, date, timeSlot } = body;
const { id } = await params;
const appointment = await prisma.appointment.findUnique({
where: { id },
include: {
patient: true,
dentist: true,
service: true,
},
});
if (!appointment) {
return NextResponse.json(
{ error: "Appointment not found" },
{ status: 404 }
);
}
// Update appointment
const updatedAppointment = await prisma.appointment.update({
where: { id },
data: {
...(status && { status }),
...(cancelReason && { cancelReason }),
...(date && { date: new Date(date) }),
...(timeSlot && { timeSlot }),
},
include: {
patient: true,
dentist: true,
service: true,
},
});
// Create notifications based on action
if (status === "cancelled") {
await prisma.notification.create({
data: {
userId: appointment.patientId,
title: "Appointment Cancelled",
message: `Your appointment for ${appointment.service.name} on ${new Date(appointment.date).toLocaleDateString()} has been cancelled.`,
type: "email",
},
});
await prisma.notification.create({
data: {
userId: appointment.dentistId,
title: "Appointment Cancelled",
message: `Appointment with ${appointment.patient.name} on ${new Date(appointment.date).toLocaleDateString()} has been cancelled.`,
type: "email",
},
});
} else if (status === "confirmed") {
await prisma.notification.create({
data: {
userId: appointment.patientId,
title: "Appointment Confirmed",
message: `Your appointment for ${appointment.service.name} on ${new Date(appointment.date).toLocaleDateString()} has been confirmed.`,
type: "email",
},
});
} else if (date || timeSlot) {
await prisma.notification.create({
data: {
userId: appointment.patientId,
title: "Appointment Rescheduled",
message: `Your appointment has been rescheduled to ${new Date(updatedAppointment.date).toLocaleDateString()} at ${updatedAppointment.timeSlot}.`,
type: "email",
},
});
await prisma.notification.create({
data: {
userId: appointment.dentistId,
title: "Appointment Rescheduled",
message: `Appointment with ${appointment.patient.name} has been rescheduled to ${new Date(updatedAppointment.date).toLocaleDateString()} at ${updatedAppointment.timeSlot}.`,
type: "email",
},
});
}
return NextResponse.json(updatedAppointment);
} catch (error) {
console.error("Error updating appointment:", error);
return NextResponse.json(
{ error: "Failed to update appointment" },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
await prisma.appointment.delete({
where: { id },
});
return NextResponse.json({ message: "Appointment deleted successfully" });
} catch (error) {
console.error("Error deleting appointment:", error);
return NextResponse.json(
{ error: "Failed to delete appointment" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,270 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/types/prisma";
import { auth } from "@/lib/auth-session/auth";
import { Resend } from "resend";
import { createElement } from "react";
import DentalInvoice from "@/components/emails/email-bookings";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { appointmentData } = body;
if (!appointmentData) {
return NextResponse.json(
{ error: "Appointment data is required" },
{ status: 400 }
);
}
interface ServiceItem {
qty: number;
description: string;
unitPrice: number;
total: number;
}
const { patientId, personalInfo, appointment, services, specialRequests } =
appointmentData as {
patientId: string;
personalInfo: {
firstName: string;
lastName: string;
email: string;
address?: string;
city?: string;
contactNumber?: string;
};
appointment: {
dentistId: string;
dentistName: string;
date: string;
time: string;
};
services: ServiceItem[];
specialRequests: string;
};
// Validate required fields
if (
!patientId ||
!appointment.dentistId ||
!appointment.date ||
!appointment.time
) {
return NextResponse.json(
{ error: "Missing required appointment fields" },
{ status: 400 }
);
}
// Check if time slot is already booked
const existingAppointment = await prisma.appointment.findFirst({
where: {
dentistId: appointment.dentistId,
date: new Date(appointment.date),
timeSlot: appointment.time,
status: {
in: ["pending", "confirmed"],
},
},
});
if (existingAppointment) {
return NextResponse.json(
{ error: "This time slot is already booked" },
{ status: 409 }
);
}
// Create appointments for each service
const createdAppointments = [];
for (const service of services) {
if (service.qty > 0 && service.description) {
// Find the service in database
const dbService = await prisma.service.findFirst({
where: { name: service.description },
});
if (dbService) {
const newAppointment = await prisma.appointment.create({
data: {
patientId,
dentistId: appointment.dentistId,
serviceId: dbService.id,
date: new Date(appointment.date),
timeSlot: appointment.time,
notes: specialRequests || null,
status: "pending",
},
include: {
patient: true,
dentist: true,
service: true,
},
});
createdAppointments.push(newAppointment);
}
}
}
if (createdAppointments.length === 0) {
return NextResponse.json(
{ error: "No valid services selected" },
{ status: 400 }
);
}
// Send confirmation email to patient with professional invoice template
// Generate invoice number
const invoiceNumber = `INV-${Date.now()}-${Math.random().toString(36).substring(7).toUpperCase()}`;
const invoiceDate = new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
const dueDate = new Date(
Date.now() + 30 * 24 * 60 * 60 * 1000
).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
// Format appointment date and time
const formattedAppointmentDate = new Date(
appointment.date
).toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
// Calculate next appointment (6 months from now for regular checkup)
const nextApptDate = new Date(appointment.date);
nextApptDate.setMonth(nextApptDate.getMonth() + 6);
const nextAppointmentDate = nextApptDate.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
// Calculate total duration (assuming each service takes 60 minutes)
const totalDuration = services
.filter((s) => s.qty > 0)
.reduce((sum, s) => sum + s.qty * 60, 0);
// Calculate financial totals
const subtotal = services
.filter((s) => s.qty > 0)
.reduce((sum, s) => sum + s.total, 0);
const tax = subtotal * 0.12; // 12% tax
const totalDue = subtotal + tax;
// Filter services with qty > 0 for email
const activeServices = services.filter((s) => s.qty > 0);
try {
console.log("Attempting to send email to:", personalInfo.email);
console.log(
"From address:",
`${process.env.EMAIL_SENDER_NAME} <${process.env.EMAIL_SENDER_ADDRESS}>`
);
const emailResult = await resend.emails.send({
from: `${process.env.EMAIL_SENDER_NAME} <${process.env.EMAIL_SENDER_ADDRESS}>`,
to: personalInfo.email,
subject: `Appointment Confirmation - Invoice #${invoiceNumber}`,
react: createElement(DentalInvoice, {
invoiceNumber,
invoiceDate,
dueDate,
patientName: `${personalInfo.firstName} ${personalInfo.lastName}`,
patientAddress: personalInfo.address || "N/A",
patientCity: personalInfo.city || "N/A",
patientPhone: personalInfo.contactNumber || "N/A",
patientEmail: personalInfo.email,
bookingId: createdAppointments[0]?.id || "PENDING",
appointmentDate: formattedAppointmentDate,
appointmentTime: appointment.time,
doctorName: appointment.dentistName,
treatmentRoom: "Room 1",
appointmentDuration: `${totalDuration} minutes`,
reasonForVisit:
specialRequests ||
services
.filter((s) => s.qty > 0)
.map((s) => s.description)
.join(", "),
pdfDownloadUrl: `${process.env.NEXT_PUBLIC_APP_URL}/patient/appointments`,
paymentStatus: "Pending Payment",
nextAppointmentDate,
nextAppointmentTime: appointment.time,
nextAppointmentPurpose: "Regular Dental Checkup & Cleaning",
services: activeServices,
subtotal,
tax,
totalDue,
}),
});
console.log("Email sent successfully:", emailResult);
} catch (emailError) {
console.error("Error sending email:", emailError);
console.error(
"Email error details:",
JSON.stringify(emailError, null, 2)
);
// Don't fail the appointment creation if email fails
}
// Create notification for patient
await prisma.notification.create({
data: {
userId: patientId,
title: "Appointment Booked",
message: `Your appointment with Dr. ${appointment.dentistName} has been booked for ${new Date(appointment.date).toLocaleDateString()} at ${appointment.time}`,
type: "email",
},
});
// Create notification for dentist
await prisma.notification.create({
data: {
userId: appointment.dentistId,
title: "New Appointment",
message: `New appointment request from ${personalInfo.firstName} ${personalInfo.lastName} for ${new Date(appointment.date).toLocaleDateString()} at ${appointment.time}`,
type: "email",
},
});
return NextResponse.json(
{
success: true,
appointments: createdAppointments,
message:
"Appointment booked successfully! Check your email for confirmation.",
},
{ status: 201 }
);
} catch (error) {
console.error("Error booking appointment:", error);
return NextResponse.json(
{ error: "Failed to book appointment" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,165 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/types/prisma";
import { auth } from "@/lib/auth-session/auth";
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
export async function POST(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { patientId, dentistId, serviceId, date, timeSlot, notes } = body;
// Validate required fields
if (!patientId || !dentistId || !serviceId || !date || !timeSlot) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
);
}
// Check if time slot is already booked
const existingAppointment = await prisma.appointment.findFirst({
where: {
dentistId,
date: new Date(date),
timeSlot,
status: {
in: ["pending", "confirmed"],
},
},
});
if (existingAppointment) {
return NextResponse.json(
{ error: "This time slot is already booked" },
{ status: 409 }
);
}
// Create appointment
const appointment = await prisma.appointment.create({
data: {
patientId,
dentistId,
serviceId,
date: new Date(date),
timeSlot,
notes: notes || null,
status: "pending",
},
include: {
patient: true,
dentist: true,
service: true,
},
});
// Create notification for patient
await prisma.notification.create({
data: {
userId: patientId,
title: "Appointment Booked",
message: `Your appointment for ${appointment.service.name} has been booked for ${new Date(date).toLocaleDateString()} at ${timeSlot}`,
type: "email",
},
});
// Create notification for dentist
await prisma.notification.create({
data: {
userId: dentistId,
title: "New Appointment",
message: `New appointment request from ${appointment.patient.name} for ${new Date(date).toLocaleDateString()} at ${timeSlot}`,
type: "email",
},
});
return NextResponse.json(appointment, { status: 201 });
} catch (error) {
console.error("Error creating appointment:", error);
return NextResponse.json(
{ error: "Failed to create appointment" },
{ status: 500 }
);
}
}
export async function GET(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId");
const role = searchParams.get("role");
let appointments;
if (role === "patient") {
appointments = await safeFindManyAppointments({
take: 100, // Limit to 100 most recent appointments
where: {
patientId: userId || session.user.id,
},
include: {
dentist: true,
service: true,
payment: true,
},
orderBy: {
date: "desc",
},
});
} else if (role === "dentist") {
appointments = await safeFindManyAppointments({
take: 100, // Limit to 100 most recent appointments
where: {
dentistId: userId || session.user.id,
},
include: {
patient: true,
service: true,
payment: true,
},
orderBy: {
date: "desc",
},
});
} else {
// Admin - get all appointments with pagination limit
// Use safe find to filter out orphaned appointments
appointments = await safeFindManyAppointments({
take: 100, // Limit to 100 most recent appointments
include: {
patient: true,
dentist: true,
service: true,
payment: true,
},
orderBy: {
date: "desc",
},
});
}
return NextResponse.json(appointments);
} catch (error) {
console.error("Error fetching appointments:", error);
return NextResponse.json(
{ error: "Failed to fetch appointments" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth-session/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth.handler);

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth-session/auth";
/**
* POST /api/auth/resend-verification
* Resends the email verification link to the user
*/
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { email } = body;
if (!email) {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}
// Use Better Auth's sendVerificationEmail method
await auth.api.sendVerificationEmail({
body: { email },
});
return NextResponse.json(
{ message: "Verification email sent successfully" },
{ status: 200 }
);
} catch (error) {
console.error("Failed to resend verification email:", error);
return NextResponse.json(
{ error: "Failed to send verification email" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,50 @@
import { auth } from "@/lib/auth-session/auth";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/types/prisma";
export async function GET() {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json({ error: "No session found" }, { status: 401 });
}
// Fetch the full user object from database to get the role
// This is necessary because session cache doesn't include additional fields
if (session.user) {
const dbUser = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
name: true,
email: true,
image: true,
role: true,
},
});
if (dbUser) {
// Merge the role from database into the session user object
session.user = {
...session.user,
role: dbUser.role || "patient", // Default to patient if no role set
};
} else {
// Fallback if user not found in database
session.user.role = "patient";
}
}
return NextResponse.json(session);
} catch (error) {
console.error("Session fetch error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth-session/auth";
/**
* Debug endpoint to check session and cookie status
* Access this at: /api/debug-session
*
* REMOVE THIS FILE AFTER DEBUGGING
*/
export async function GET(request: NextRequest) {
try {
// Get all cookies
const allCookies = request.cookies.getAll();
const sessionToken = request.cookies.get("better-auth.session_token");
// Try to get session from Better Auth
let session = null;
let sessionError = null;
try {
session = await auth.api.getSession({
headers: request.headers,
});
} catch (error) {
sessionError = error instanceof Error ? error.message : String(error);
}
// Get request details
const info = {
timestamp: new Date().toISOString(),
environment: {
nodeEnv: process.env.NODE_ENV,
hasAuthUrl: !!process.env.BETTER_AUTH_URL,
authUrl: process.env.BETTER_AUTH_URL,
hasAppUrl: !!process.env.NEXT_PUBLIC_APP_URL,
appUrl: process.env.NEXT_PUBLIC_APP_URL,
},
request: {
url: request.url,
origin: request.headers.get("origin"),
referer: request.headers.get("referer"),
host: request.headers.get("host"),
protocol: request.headers.get("x-forwarded-proto") || "unknown",
},
cookies: {
total: allCookies.length,
names: allCookies.map((c) => c.name),
hasSessionToken: !!sessionToken,
sessionTokenValue: sessionToken?.value
? `${sessionToken.value.substring(0, 20)}...`
: null,
},
session: session
? {
userId: session.user?.id,
userEmail: session.user?.email,
sessionId: session.session?.id,
expiresAt: session.session?.expiresAt,
}
: null,
sessionError,
};
return NextResponse.json(info, {
status: 200,
headers: {
"Cache-Control": "no-store, no-cache, must-revalidate",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,49 @@
import DentalAppointmentReminder from "@/components/emails/email-remainder";
import { Resend } from "resend";
import React from "react";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(request: Request) {
const body = await request.json();
const {
patientName,
appointmentDate,
appointmentTime,
doctorName,
treatmentType,
duration,
clinicPhone,
clinicEmail,
clinicAddress,
to,
} = body;
try {
const { data, error } = await resend.emails.send({
from: `Dental U Care <${process.env.EMAIL_SENDER_ADDRESS || "onboarding@dentalucare.tech"}>`,
to: [to],
subject: `Dental Appointment Reminder for ${patientName}`,
react: (
<DentalAppointmentReminder
patientName={patientName}
appointmentDate={appointmentDate}
appointmentTime={appointmentTime}
doctorName={doctorName}
treatmentType={treatmentType}
duration={duration}
clinicPhone={clinicPhone}
clinicEmail={clinicEmail}
clinicAddress={clinicAddress || ""}
/>
),
});
if (error) {
return Response.json({ error }, { status: 500 });
}
return Response.json(data);
} catch (error) {
return Response.json({ error }, { status: 500 });
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/types/prisma";
import { auth } from "@/lib/auth-session/auth";
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: await import("next/headers").then((mod) => mod.headers()),
});
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only admin can change roles
if (session.user.role !== "admin") {
return NextResponse.json(
{ error: "Forbidden: Admin access required" },
{ status: 403 }
);
}
const { id } = await params;
const body = await request.json();
const { role } = body;
// Validate role
if (!role || !["patient", "dentist", "admin"].includes(role)) {
return NextResponse.json(
{ error: "Invalid role. Must be patient, dentist, or admin" },
{ status: 400 }
);
}
// Check if user exists
const user = await prisma.user.findUnique({
where: { id },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Prevent changing own role
if (user.id === session.user.id) {
return NextResponse.json(
{ error: "You cannot change your own role" },
{ status: 400 }
);
}
// Update user role
const updatedUser = await prisma.user.update({
where: { id },
data: { role },
});
return NextResponse.json({
success: true,
user: updatedUser,
message: `Role changed to ${role} successfully`,
});
} catch (error) {
console.error("Error changing user role:", error);
return NextResponse.json(
{ error: "Failed to change user role" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,5 @@
import PrivacyPolicy from "@/components/landing/privacy-policy";
export default function PrivacyPolicyPage() {
return <PrivacyPolicy />;
}

View File

@@ -0,0 +1,5 @@
import TermsAndConditions from "@/components/landing/terms-and-conditions";
export default function TermsAndConditionsPage() {
return <TermsAndConditions />;
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,49 @@
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { getCurrentUser } from "@/lib/auth-session/auth-server";
import { redirect } from "next/navigation";
export default async function ForbiddenPage() {
const user = await getCurrentUser();
// If not authenticated, redirect to sign-in
if (!user) {
redirect("/sign-in");
}
// Determine the appropriate dashboard based on role
const getDashboardUrl = () => {
switch (user.role) {
case "admin":
return "/admin";
case "dentist":
return "/dentist";
case "patient":
return "/patient";
default:
return "/profile";
}
};
return (
<main className="flex grow items-center justify-center px-4 text-center">
<div className="space-y-6">
<div className="space-y-2">
<h1 className="text-4xl font-bold">403</h1>
<h2 className="text-2xl font-semibold">Access Denied</h2>
<p className="text-muted-foreground">
You don&apos;t have permission to access this page.
</p>
</div>
<div className="flex gap-4 justify-center">
<Button asChild variant="default">
<Link href={getDashboardUrl()}>Go to Dashboard</Link>
</Button>
<Button asChild variant="outline">
<Link href="/">Go Home</Link>
</Button>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function UnauthorizedPage() {
const pathname = usePathname();
return (
<main className="flex grow items-center justify-center px-4 text-center">
<div className="space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold">401 - Unauthorized</h1>
<p className="text-muted-foreground">Please sign in to continue.</p>
</div>
<div>
<Button asChild>
<Link href={`/sign-in?redirect=${pathname}`}>Sign in</Link>
</Button>
</div>
</div>
</main>
);
}

5
app/get-started/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import GetStartedGuide from "@/components/landing/get-started";
export default function GetStartedPage() {
return <GetStartedGuide />;
}

235
app/globals.css Normal file
View File

@@ -0,0 +1,235 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0.1884 0.0128 248.5103);
--card: oklch(0.9784 0.0011 197.1387);
--card-foreground: oklch(0.1884 0.0128 248.5103);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.1884 0.0128 248.5103);
--primary: oklch(0.6723 0.1606 244.9955);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.1884 0.0128 248.5103);
--secondary-foreground: oklch(1.0000 0 0);
--muted: oklch(0.9222 0.0013 286.3737);
--muted-foreground: oklch(0.1884 0.0128 248.5103);
--accent: oklch(0.9392 0.0166 250.8453);
--accent-foreground: oklch(0.6723 0.1606 244.9955);
--destructive: oklch(0.6188 0.2376 25.7658);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.9317 0.0118 231.6594);
--input: oklch(0.9809 0.0025 228.7836);
--ring: oklch(0.6818 0.1584 243.3540);
--chart-1: oklch(0.6723 0.1606 244.9955);
--chart-2: oklch(0.6907 0.1554 160.3454);
--chart-3: oklch(0.8214 0.1600 82.5337);
--chart-4: oklch(0.7064 0.1822 151.7125);
--chart-5: oklch(0.5919 0.2186 10.5826);
--sidebar: oklch(0.9784 0.0011 197.1387);
--sidebar-foreground: oklch(0.1884 0.0128 248.5103);
--sidebar-primary: oklch(0.6723 0.1606 244.9955);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.9392 0.0166 250.8453);
--sidebar-accent-foreground: oklch(0.6723 0.1606 244.9955);
--sidebar-border: oklch(0.9271 0.0101 238.5177);
--sidebar-ring: oklch(0.6818 0.1584 243.3540);
--font-sans: Open Sans, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--radius: 1.3rem;
--shadow-x: 0px;
--shadow-y: 2px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0;
--shadow-color: rgba(29,161,242,0.15);
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(0.9328 0.0025 228.7857);
--card: oklch(0.2097 0.0080 274.5332);
--card-foreground: oklch(0.8853 0 0);
--popover: oklch(0 0 0);
--popover-foreground: oklch(0.9328 0.0025 228.7857);
--primary: oklch(0.6692 0.1607 245.0110);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9622 0.0035 219.5331);
--secondary-foreground: oklch(0.1884 0.0128 248.5103);
--muted: oklch(0.2090 0 0);
--muted-foreground: oklch(0.5637 0.0078 247.9662);
--accent: oklch(0.1928 0.0331 242.5459);
--accent-foreground: oklch(0.6692 0.1607 245.0110);
--destructive: oklch(0.6188 0.2376 25.7658);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.2674 0.0047 248.0045);
--input: oklch(0.3020 0.0288 244.8244);
--ring: oklch(0.6818 0.1584 243.3540);
--chart-1: oklch(0.6723 0.1606 244.9955);
--chart-2: oklch(0.6907 0.1554 160.3454);
--chart-3: oklch(0.8214 0.1600 82.5337);
--chart-4: oklch(0.7064 0.1822 151.7125);
--chart-5: oklch(0.5919 0.2186 10.5826);
--sidebar: oklch(0.2097 0.0080 274.5332);
--sidebar-foreground: oklch(0.8853 0 0);
--sidebar-primary: oklch(0.6818 0.1584 243.3540);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.1928 0.0331 242.5459);
--sidebar-accent-foreground: oklch(0.6692 0.1607 245.0110);
--sidebar-border: oklch(0.3795 0.0220 240.5943);
--sidebar-ring: oklch(0.6818 0.1584 243.3540);
--font-sans: Open Sans, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--radius: 1.3rem;
--shadow-x: 0px;
--shadow-y: 2px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0;
--shadow-color: rgba(29,161,242,0.25);
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
--animate-marquee: marquee var(--duration) infinite linear;
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
--animate-aurora: aurora 60s linear infinite;
@keyframes aurora {
from {
backgroundPosition: 50% 50%, 50% 50%;
}
to {
backgroundPosition: 350% 50%, 350% 50%;
}
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-100% - var(--gap)));
}
}
@keyframes marquee-vertical {
from {
transform: translateY(0);
}
to {
transform: translateY(calc(-100% - var(--gap)));
}
}
@layer base {
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--foreground);
border-radius: 5px;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--foreground) transparent;
}
}
@utility container {
margin-inline: auto;
padding-inline: 1.5rem;
@media (width >= --theme(--breakpoint-sm)) {
max-width: none;
}
@media (width >= 1440px) {
padding-inline: 2rem;
max-width: 1440px;
}
}
/** Smooth scroll **/
html {
scroll-behavior: smooth;
}

42
app/layout.tsx Normal file
View File

@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/provider/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { ReactNode } from "react";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Dental U Care",
description: "Your one-stop solution for dental care",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head />
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster position="top-right" />
</ThemeProvider>
</body>
</html>
);
}

56
app/page.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { Contact } from "@/components/landing/contact";
import { Footer } from "@/components/landing/footer";
import { Hero } from "@/components/landing/hero";
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
import { Pricing } from "@/components/landing/pricing";
import { Features } from "@/components/landing/features";
import { Team } from "@/components/landing/team";
import { About } from "@/components/landing/about";
import { Services } from "@/components/landing/services";
// import { AuroraBackground } from "@/components/ui/shadcn-io/aurora-background";
export default function Home() {
return (
<main className=" py-4 max-w-7xl mx-auto relative z-10 justify-center px-4 sm:px-6 lg:px-8">
<NavbarWrapper />
<section id="home" className="py-4 my-4">
<Hero />
</section>
<section id="about">
<About />
</section>
<section
id="team"
className="rounded-2xl shadow-[0_20px_60px_-15px_rgba(251,191,36,0.5)] dark:shadow-[0_20px_60px_-15px_rgba(251,191,36,0.3)] p-8 my-8"
>
<Team />
</section>
<section
id="features"
className="rounded-2xl shadow-[0_20px_60px_-15px_rgba(249,115,22,0.5)] dark:shadow-[0_20px_60px_-15px_rgba(249,115,22,0.3)] p-8 my-8"
>
<Features />
</section>
<section
id="services"
className="rounded-2xl shadow-[0_20px_60px_-15px_rgba(59,130,246,0.5)] dark:shadow-[0_20px_60px_-15px_rgba(59,130,246,0.3)] p-8 my-8"
>
<Services />
</section>
<section
id="pricing"
className="rounded-2xl shadow-[0_20px_60px_-15px_rgba(168,85,247,0.5)] dark:shadow-[0_20px_60px_-15px_rgba(168,85,247,0.3)] p-8 my-8"
>
<Pricing />
</section>
<section
id="contact"
className="rounded-2xl p-8 my-8 shadow-[0_18px_30px_rgba(236,72,153,0.18)] dark:shadow-[0_18px_30px_rgba(236,72,153,0.12)]"
>
<Contact />
</section>
<Footer />
</main>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
import { Navbar } from "@/components/landing/navbar";
export default function PatientResourcesPage() {
return (
<>
<Navbar />
<main className="min-h-screen bg-background flex flex-col items-center justify-center">
<h1 className="text-4xl font-bold mb-6">Patient Resources</h1>
<p className="text-lg text-muted-foreground mb-8 max-w-xl text-center">
Find new patient forms, insurance information, and financing options to help you prepare for your visit and manage your dental care.
</p>
{/* Add more resource links or components here as needed */}
</main>
</>
);
}

View File

@@ -0,0 +1,11 @@
import { CosmeticDentistry } from "@/components/services/CosmeticDentistry";
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
export default function CosmeticDentistryPage() {
return (
<main className="py-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<NavbarWrapper />
<CosmeticDentistry />
</main>
);
}

View File

@@ -0,0 +1,11 @@
import { EmergencyCare } from "@/components/services/EmergencyCare";
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
export default function EmergencyCarePage() {
return (
<main className="py-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<NavbarWrapper />
<EmergencyCare />
</main>
);
}

View File

@@ -0,0 +1,11 @@
import { Orthodontics } from "@/components/services/Orthodontics";
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
export default function OrthodonticsPage() {
return (
<main className="py-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<NavbarWrapper />
<Orthodontics />
</main>
);
}

View File

@@ -0,0 +1,11 @@
import { PediatricDentistry } from "@/components/services/PediatricDentistry";
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
export default function PediatricDentistryPage() {
return (
<main className="py-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<NavbarWrapper />
<PediatricDentistry />
</main>
);
}

View File

@@ -0,0 +1,11 @@
import {PreventiveCare} from "@/components/services/PreventiveCare";
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
export default function PreventiveCarePage() {
return (
<main className="py-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<NavbarWrapper />
<PreventiveCare />
</main>
);
}

33
components.json Normal file
View File

@@ -0,0 +1,33 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@acme": "https://registry.acme.com/{name}.json",
"@tailark": "https://tailark.com/r/{name}.json",
"@shadcnblocks": {
"url": "https://shadcnblocks.com/r/{name}",
"headers": {
"Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}"
}
},
"@shadcnio": "https://www.shadcn.io/r/{name}.json",
"@reui": "https://reui.io/r/{name}.json"
}
}

View File

@@ -0,0 +1,883 @@
"use client";
import * as React from "react";
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconDotsVertical,
IconLayoutColumns,
IconSearch,
} from "@tabler/icons-react";
import { Calendar, Clock, User, Mail } from "lucide-react";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import {
confirmAppointments,
cancelAppointments,
completeAppointments,
deleteAppointments,
deleteAppointment,
} from "@/lib/actions/admin-actions";
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
type Appointment = {
id: string;
date: Date;
timeSlot: string;
status: string;
notes: string | null;
patient: {
name: string;
email: string;
};
dentist: {
name: string;
};
service: {
name: string;
price: string;
};
payment: {
status: string;
amount: number;
} | null;
};
const getStatusBadge = (status: string) => {
const variants: Record<
string,
"default" | "secondary" | "destructive" | "outline"
> = {
pending: "secondary",
confirmed: "default",
cancelled: "destructive",
completed: "outline",
rescheduled: "secondary",
};
return (
<Badge variant={variants[status] || "default"} className="text-xs">
{status.toUpperCase()}
</Badge>
);
};
const getPaymentBadge = (status: string) => {
const variants: Record<
string,
"default" | "secondary" | "destructive" | "outline"
> = {
paid: "default",
pending: "secondary",
failed: "destructive",
refunded: "outline",
};
return (
<Badge variant={variants[status] || "default"} className="text-xs">
{status.toUpperCase()}
</Badge>
);
};
const columns: ColumnDef<Appointment>[] = [
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
id: "patientName",
accessorFn: (row) => row.patient.name,
header: "Patient",
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.patient.name}</p>
<p className="text-xs text-muted-foreground">
{row.original.patient.email}
</p>
</div>
),
enableHiding: false,
},
{
id: "dentistName",
accessorFn: (row) => row.dentist.name,
header: "Dentist",
cell: ({ row }) => <span>Dr. {row.original.dentist.name}</span>,
},
{
id: "serviceName",
accessorFn: (row) => row.service.name,
header: "Service",
cell: ({ row }) => row.original.service.name,
},
{
accessorKey: "date",
header: "Date & Time",
cell: ({ row }) => (
<div>
<p>{new Date(row.original.date).toLocaleDateString()}</p>
<p className="text-xs text-muted-foreground">{row.original.timeSlot}</p>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => getStatusBadge(row.original.status),
},
{
id: "paymentStatus",
accessorFn: (row) => row.payment?.status || "none",
header: "Payment",
cell: ({ row }) =>
row.original.payment ? getPaymentBadge(row.original.payment.status) : "-",
},
{
accessorKey: "amount",
header: () => <div className="text-right">Amount</div>,
cell: ({ row }) => {
if (row.original.payment) {
const amount = row.original.payment.amount;
return <div className="text-right">{amount.toFixed(2)}</div>;
}
// service.price is a string (e.g., "₱500 ₱1,500" or "₱1,500")
const price = row.original.service.price;
return <div className="text-right">{price}</div>;
},
},
];
type AdminAppointmentsTableProps = {
appointments: Appointment[];
};
export function AdminAppointmentsTable({
appointments,
}: AdminAppointmentsTableProps) {
const router = useRouter();
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
);
const [sorting, setSorting] = React.useState<SortingState>([]);
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
});
const [isLoading, setIsLoading] = React.useState(false);
const [selectedAppointment, setSelectedAppointment] =
React.useState<Appointment | null>(null);
const formatPrice = (price: number | string): string => {
if (typeof price === "string") {
return price;
}
if (isNaN(price)) {
return "Contact for pricing";
}
return `${price.toLocaleString()}`;
};
const handleBulkAction = async (
action: (ids: string[]) => Promise<{ success: boolean; message: string }>,
actionName: string
) => {
const selectedRows = table.getFilteredSelectedRowModel().rows;
const ids = selectedRows.map((row) => row.original.id);
if (ids.length === 0) {
toast.error("No appointments selected");
return;
}
setIsLoading(true);
try {
const result = await action(ids);
if (result.success) {
toast.success(result.message);
setRowSelection({});
router.refresh();
} else {
toast.error(result.message);
}
} catch (error) {
toast.error(`Failed to ${actionName}`);
console.error(error);
} finally {
setIsLoading(false);
}
};
const handleSingleAction = async (
action: () => Promise<{ success: boolean; message: string }>,
actionName: string
) => {
setIsLoading(true);
try {
const result = await action();
if (result.success) {
toast.success(result.message);
router.refresh();
} else {
toast.error(result.message);
}
} catch (error) {
toast.error(`Failed to ${actionName}`);
console.error(error);
} finally {
setIsLoading(false);
}
};
const actionsColumn: ColumnDef<Appointment> = {
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
disabled={isLoading}
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onClick={() => setSelectedAppointment(row.original)}
>
View Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toast.info("Edit feature coming soon")}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toast.info("Reschedule feature coming soon")}
>
Reschedule
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
handleSingleAction(
() => cancelAppointments([row.original.id]),
"cancel appointment"
)
}
>
Cancel
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() =>
handleSingleAction(
() => deleteAppointment(row.original.id),
"delete appointment"
)
}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
};
const columnsWithActions = [...columns, actionsColumn];
const table = useReactTable({
data: appointments,
columns: columnsWithActions,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
});
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="relative w-full max-w-sm">
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
<Input
placeholder="Search appointments..."
value={
(table.getColumn("patientName")?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table.getColumn("patientName")?.setFilterValue(event.target.value)
}
className="pl-8"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Bulk Actions Toolbar */}
{table.getFilteredSelectedRowModel().rows.length > 0 && (
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
<span className="text-sm font-medium">
{table.getFilteredSelectedRowModel().rows.length} selected
</span>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={isLoading}
onClick={() =>
handleBulkAction(confirmAppointments, "confirm appointments")
}
>
Confirm Selected
</Button>
<Button
variant="outline"
size="sm"
disabled={isLoading}
onClick={() => toast.info("Reschedule feature coming soon")}
>
Reschedule Selected
</Button>
<Button
variant="outline"
size="sm"
disabled={isLoading}
onClick={() => toast.info("Send reminders feature coming soon")}
>
Send Reminders
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={isLoading}>
More Actions
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
handleBulkAction(
completeAppointments,
"complete appointments"
)
}
>
Mark as Completed
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toast.info("Export feature coming soon")}
>
Export Selected
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
handleBulkAction(cancelAppointments, "cancel appointments")
}
>
Cancel Selected
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() =>
handleBulkAction(deleteAppointments, "delete appointments")
}
>
Delete Selected
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader className="bg-muted">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No appointments found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
{/* Appointment Details Dialog */}
<Dialog
open={!!selectedAppointment}
onOpenChange={() => setSelectedAppointment(null)}
>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-start justify-between">
<div>
<DialogTitle className="text-2xl">
Appointment Details
</DialogTitle>
<DialogDescription>
Booking ID: {selectedAppointment?.id}
</DialogDescription>
</div>
{selectedAppointment &&
getStatusBadge(selectedAppointment.status)}
</div>
</DialogHeader>
{selectedAppointment && (
<div className="space-y-6">
{/* Patient Information */}
<div className="space-y-3">
<h3 className="font-semibold text-lg border-b pb-2">
Patient Information
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-start gap-2">
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">
Patient Name
</p>
<p className="font-medium">
{selectedAppointment.patient.name}
</p>
</div>
</div>
<div className="flex items-start gap-2">
<Mail className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Email</p>
<p className="font-medium text-sm">
{selectedAppointment.patient.email}
</p>
</div>
</div>
</div>
</div>
{/* Service Information */}
<div className="space-y-3">
<h3 className="font-semibold text-lg border-b pb-2">
Service Information
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Service</p>
<p className="font-medium">
{selectedAppointment.service.name}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Price</p>
<p className="font-medium">
{formatPrice(selectedAppointment.service.price)}
</p>
</div>
</div>
</div>
{/* Appointment Schedule */}
<div className="space-y-3">
<h3 className="font-semibold text-lg border-b pb-2">
Appointment Schedule
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-start gap-2">
<Calendar className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Date</p>
<p className="font-medium">
{new Date(selectedAppointment.date).toLocaleDateString(
"en-US",
{
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}
)}
</p>
</div>
</div>
<div className="flex items-start gap-2">
<Clock className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Time</p>
<p className="font-medium">
{selectedAppointment.timeSlot}
</p>
</div>
</div>
</div>
</div>
{/* Dentist Information */}
<div className="space-y-3">
<h3 className="font-semibold text-lg border-b pb-2">
Assigned Dentist
</h3>
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">Dentist</p>
<p className="font-medium">
Dr. {selectedAppointment.dentist.name}
</p>
</div>
</div>
</div>
{/* Payment Information */}
{selectedAppointment.payment && (
<div className="space-y-3">
<h3 className="font-semibold text-lg border-b pb-2">
Payment Information
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">
Payment Status
</p>
<div className="mt-1">
{getPaymentBadge(selectedAppointment.payment.status)}
</div>
</div>
<div>
<p className="text-sm text-muted-foreground">Amount</p>
<p className="font-medium">
{selectedAppointment.payment.amount.toLocaleString()}
</p>
</div>
</div>
</div>
)}
{/* Notes */}
{selectedAppointment.notes && (
<div className="space-y-3">
<h3 className="font-semibold text-lg border-b pb-2">
Special Requests / Notes
</h3>
<p className="text-sm bg-muted p-3 rounded-lg">
{selectedAppointment.notes}
</p>
</div>
)}
{/* Admin Action Buttons */}
<div className="flex gap-2 pt-4 border-t">
{selectedAppointment.status === "pending" && (
<Button
className="flex-1"
onClick={() => {
const id = selectedAppointment.id;
setSelectedAppointment(null);
handleSingleAction(
() => confirmAppointments([id]),
"confirm appointment"
);
}}
disabled={isLoading}
>
Confirm Appointment
</Button>
)}
{(selectedAppointment.status === "pending" ||
selectedAppointment.status === "confirmed") && (
<>
<Button
variant="outline"
className="flex-1"
onClick={() => {
setSelectedAppointment(null);
toast.info("Reschedule feature coming soon");
}}
>
Reschedule
</Button>
<Button
variant="destructive"
className="flex-1"
onClick={() => {
const id = selectedAppointment.id;
setSelectedAppointment(null);
handleSingleAction(
() => cancelAppointments([id]),
"cancel appointment"
);
}}
disabled={isLoading}
>
Cancel Appointment
</Button>
</>
)}
{selectedAppointment.status === "confirmed" && (
<Button
variant="outline"
className="flex-1"
onClick={() => {
const id = selectedAppointment.id;
setSelectedAppointment(null);
handleSingleAction(
() => completeAppointments([id]),
"mark as completed"
);
}}
disabled={isLoading}
>
Mark as Completed
</Button>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,807 @@
"use client";
import * as React from "react";
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconDotsVertical,
IconLayoutColumns,
IconSearch,
} from "@tabler/icons-react";
import { User, Mail, Phone, Calendar, Award, Briefcase } from "lucide-react";
import { toast } from "sonner";
import {
updateDentistAvailability,
deleteDentist,
} from "@/lib/actions/admin-actions";
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
type Dentist = {
id: string;
name: string;
email: string;
phone: string | null;
specialization: string | null;
qualifications: string | null;
experience: string | null;
isAvailable: boolean;
createdAt: Date;
appointmentsAsDentist: Array<{
id: string;
status: string;
}>;
};
const columns: ColumnDef<Dentist>[] = [
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => (
<div>
<p className="font-medium">Dr. {row.original.name}</p>
<p className="text-xs text-muted-foreground">{row.original.email}</p>
</div>
),
enableHiding: false,
},
{
accessorKey: "specialization",
header: "Specialization",
cell: ({ row }) => row.original.specialization || "-",
},
{
accessorKey: "experience",
header: "Experience",
cell: ({ row }) => row.original.experience || "-",
},
{
accessorKey: "appointments",
header: "Appointments",
cell: ({ row }) => {
const appointmentCount = row.original.appointmentsAsDentist.length;
const completedCount = row.original.appointmentsAsDentist.filter(
(apt) => apt.status === "completed"
).length;
return (
<div className="text-sm">
<p>{appointmentCount} total</p>
<p className="text-xs text-muted-foreground">
{completedCount} completed
</p>
</div>
);
},
},
{
accessorKey: "isAvailable",
header: "Status",
cell: ({ row }) => (
<Badge
variant={row.original.isAvailable ? "default" : "secondary"}
className="text-xs"
>
{row.original.isAvailable ? "Available" : "Unavailable"}
</Badge>
),
},
{
accessorKey: "createdAt",
header: "Joined",
cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(),
},
{
id: "actions",
cell: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem>View Details</DropdownMenuItem>
<DropdownMenuItem>Edit Profile</DropdownMenuItem>
<DropdownMenuItem>View Schedule</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Deactivate</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
type AdminDentistsTableProps = {
dentists: Dentist[];
};
export function AdminDentistsTable({ dentists }: AdminDentistsTableProps) {
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
);
const [sorting, setSorting] = React.useState<SortingState>([]);
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
});
const [isLoading, setIsLoading] = React.useState(false);
const [selectedDentist, setSelectedDentist] = React.useState<Dentist | null>(
null
);
const handleBulkAvailability = async (isAvailable: boolean) => {
const selectedRows = table.getFilteredSelectedRowModel().rows;
const ids = selectedRows.map((row) => row.original.id);
if (ids.length === 0) {
toast.error("No dentists selected");
return;
}
setIsLoading(true);
try {
const result = await updateDentistAvailability(ids, isAvailable);
if (result.success) {
toast.success(result.message);
setRowSelection({});
window.location.reload();
} else {
toast.error(result.message);
}
} catch (error) {
toast.error(`Failed to update availability`);
console.error(error);
} finally {
setIsLoading(false);
}
};
const handleSingleAction = async (
action: () => Promise<{ success: boolean; message: string }>,
actionName: string
) => {
setIsLoading(true);
try {
const result = await action();
if (result.success) {
toast.success(result.message);
window.location.reload();
} else {
toast.error(result.message);
}
} catch (error) {
toast.error(`Failed to ${actionName}`);
console.error(error);
} finally {
setIsLoading(false);
}
};
const actionsColumn: ColumnDef<Dentist> = {
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
disabled={isLoading}
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => setSelectedDentist(row.original)}>
View Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toast.info("Edit feature coming soon")}
>
Edit Profile
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toast.info("Schedule feature coming soon")}
>
View Schedule
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
handleSingleAction(
() =>
updateDentistAvailability(
[row.original.id],
!row.original.isAvailable
),
row.original.isAvailable ? "set unavailable" : "set available"
)
}
>
{row.original.isAvailable ? "Set Unavailable" : "Set Available"}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => {
if (confirm("Are you sure you want to delete this dentist?")) {
handleSingleAction(
() => deleteDentist(row.original.id),
"delete dentist"
);
}
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
};
const columnsWithActions = [...columns.slice(0, -1), actionsColumn];
const table = useReactTable({
data: dentists,
columns: columnsWithActions,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
});
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="relative w-full max-w-sm">
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
<Input
placeholder="Search dentists..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("name")?.setFilterValue(event.target.value)
}
className="pl-8"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Bulk Actions Toolbar */}
{table.getFilteredSelectedRowModel().rows.length > 0 && (
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
<span className="text-sm font-medium">
{table.getFilteredSelectedRowModel().rows.length} selected
</span>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={isLoading}
onClick={() => handleBulkAvailability(true)}
>
Set Available
</Button>
<Button
variant="outline"
size="sm"
disabled={isLoading}
onClick={() => handleBulkAvailability(false)}
>
Set Unavailable
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={isLoading}>
More Actions
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
toast.info("Send notification feature coming soon")
}
>
Send Notification
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toast.info("Export feature coming soon")}
>
Export Selected
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toast.info("Schedule feature coming soon")}
>
View Schedules
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => {
if (
confirm(
"Are you sure you want to deactivate these dentists?"
)
) {
handleBulkAvailability(false);
}
}}
>
Deactivate Selected
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader className="bg-muted">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No dentists found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
{/* Dentist Details Dialog */}
<Dialog
open={!!selectedDentist}
onOpenChange={() => setSelectedDentist(null)}
>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-start justify-between">
<div>
<DialogTitle className="text-2xl">Dentist Details</DialogTitle>
<DialogDescription>ID: {selectedDentist?.id}</DialogDescription>
</div>
{selectedDentist && (
<Badge
variant={
selectedDentist.isAvailable ? "default" : "secondary"
}
className="text-xs"
>
{selectedDentist.isAvailable ? "Available" : "Unavailable"}
</Badge>
)}
</div>
</DialogHeader>
{selectedDentist && (
<div className="space-y-6">
{/* Personal Information */}
<div className="space-y-3">
<h3 className="font-semibold text-lg border-b pb-2">
Personal Information
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-start gap-2">
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Full Name</p>
<p className="font-medium">Dr. {selectedDentist.name}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Mail className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Email</p>
<p className="font-medium text-sm">
{selectedDentist.email}
</p>
</div>
</div>
{selectedDentist.phone && (
<div className="flex items-start gap-2">
<Phone className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Phone</p>
<p className="font-medium">{selectedDentist.phone}</p>
</div>
</div>
)}
<div className="flex items-start gap-2">
<Calendar className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Joined</p>
<p className="font-medium">
{new Date(selectedDentist.createdAt).toLocaleDateString(
"en-US",
{
year: "numeric",
month: "long",
day: "numeric",
}
)}
</p>
</div>
</div>
</div>
</div>
{/* Professional Information */}
<div className="space-y-3">
<h3 className="font-semibold text-lg border-b pb-2">
Professional Information
</h3>
<div className="grid grid-cols-2 gap-4">
{selectedDentist.specialization && (
<div className="flex items-start gap-2">
<Award className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">
Specialization
</p>
<p className="font-medium">
{selectedDentist.specialization}
</p>
</div>
</div>
)}
{selectedDentist.experience && (
<div className="flex items-start gap-2">
<Briefcase className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">
Experience
</p>
<p className="font-medium">
{selectedDentist.experience}
</p>
</div>
</div>
)}
</div>
{selectedDentist.qualifications && (
<div className="mt-3">
<p className="text-sm text-muted-foreground mb-1">
Qualifications
</p>
<p className="text-sm bg-muted p-3 rounded-lg">
{selectedDentist.qualifications}
</p>
</div>
)}
</div>
{/* Appointment Statistics */}
<div className="space-y-3">
<h3 className="font-semibold text-lg border-b pb-2">
Appointment Statistics
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="bg-muted p-4 rounded-lg">
<p className="text-2xl font-bold">
{selectedDentist.appointmentsAsDentist.length}
</p>
<p className="text-sm text-muted-foreground">
Total Appointments
</p>
</div>
<div className="bg-muted p-4 rounded-lg">
<p className="text-2xl font-bold">
{
selectedDentist.appointmentsAsDentist.filter(
(apt) => apt.status === "completed"
).length
}
</p>
<p className="text-sm text-muted-foreground">Completed</p>
</div>
<div className="bg-muted p-4 rounded-lg">
<p className="text-2xl font-bold">
{
selectedDentist.appointmentsAsDentist.filter(
(apt) =>
apt.status === "pending" ||
apt.status === "confirmed"
).length
}
</p>
<p className="text-sm text-muted-foreground">Upcoming</p>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2 pt-4 border-t">
<Button
variant="outline"
className="flex-1"
onClick={() => {
setSelectedDentist(null);
toast.info("Edit feature coming soon");
}}
>
Edit Profile
</Button>
<Button
variant="outline"
className="flex-1"
onClick={() => {
setSelectedDentist(null);
toast.info("Schedule feature coming soon");
}}
>
View Schedule
</Button>
<Button
variant={selectedDentist.isAvailable ? "outline" : "default"}
className="flex-1"
onClick={() => {
const id = selectedDentist.id;
const newStatus = !selectedDentist.isAvailable;
setSelectedDentist(null);
handleSingleAction(
() => updateDentistAvailability([id], newStatus),
newStatus ? "set available" : "set unavailable"
);
}}
disabled={isLoading}
>
{selectedDentist.isAvailable
? "Set Unavailable"
: "Set Available"}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,437 @@
"use client";
import * as React from "react";
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconDotsVertical,
IconLayoutColumns,
IconSearch,
IconMail,
IconPhone,
} from "@tabler/icons-react";
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type Patient = {
id: string;
name: string;
email: string;
phone: string | null;
dateOfBirth: Date | null;
medicalHistory: string | null;
createdAt: Date;
appointmentsAsPatient: Array<{
id: string;
status: string;
}>;
payments: Array<{
id: string;
amount: number;
}>;
};
const columns: ColumnDef<Patient>[] = [
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
enableHiding: false,
},
{
accessorKey: "contact",
header: "Contact",
cell: ({ row }) => (
<div className="space-y-1">
<div className="flex items-center gap-1 text-sm">
<IconMail className="size-3" />
<span className="text-xs">{row.original.email}</span>
</div>
{row.original.phone && (
<div className="flex items-center gap-1 text-sm">
<IconPhone className="size-3" />
<span className="text-xs">{row.original.phone}</span>
</div>
)}
</div>
),
},
{
accessorKey: "dateOfBirth",
header: "Date of Birth",
cell: ({ row }) =>
row.original.dateOfBirth
? new Date(row.original.dateOfBirth).toLocaleDateString()
: "-",
},
{
accessorKey: "appointments",
header: "Appointments",
cell: ({ row }) => row.original.appointmentsAsPatient.length,
},
{
accessorKey: "totalSpent",
header: () => <div className="text-right">Total Spent</div>,
cell: ({ row }) => {
const totalSpent = row.original.payments.reduce(
(sum, payment) => sum + payment.amount,
0
);
return <div className="text-right">{totalSpent.toFixed(2)}</div>;
},
},
{
accessorKey: "createdAt",
header: "Joined",
cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(),
},
{
id: "actions",
cell: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem>View Details</DropdownMenuItem>
<DropdownMenuItem>Edit Profile</DropdownMenuItem>
<DropdownMenuItem>View History</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
type AdminPatientsTableProps = {
patients: Patient[];
};
export function AdminPatientsTable({ patients }: AdminPatientsTableProps) {
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
);
const [sorting, setSorting] = React.useState<SortingState>([]);
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
});
const table = useReactTable({
data: patients,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
});
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="relative w-full max-w-sm">
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
<Input
placeholder="Search patients..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("name")?.setFilterValue(event.target.value)
}
className="pl-8"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Bulk Actions Toolbar */}
{table.getFilteredSelectedRowModel().rows.length > 0 && (
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
<span className="text-sm font-medium">
{table.getFilteredSelectedRowModel().rows.length} selected
</span>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm">
Send Email
</Button>
<Button variant="outline" size="sm">
Send SMS
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
More Actions
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Export Selected</DropdownMenuItem>
<DropdownMenuItem>Add to Group</DropdownMenuItem>
<DropdownMenuItem>Send Appointment Reminder</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
Delete Selected
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader className="bg-muted">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No patients found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,572 @@
"use client";
import * as React from "react";
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconDotsVertical,
IconLayoutColumns,
IconSearch,
} from "@tabler/icons-react";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import {
updateServiceStatus,
deleteServices,
deleteService,
duplicateService,
} from "@/lib/actions/admin-actions";
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type Service = {
id: string;
name: string;
description: string;
duration: number;
price: string;
category: string;
isActive: boolean;
createdAt: Date;
appointments: Array<{
id: string;
}>;
};
const columns: ColumnDef<Service>[] = [
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: "Service Name",
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.name}</p>
<p className="text-xs text-muted-foreground line-clamp-1">
{row.original.description}
</p>
</div>
),
enableHiding: false,
},
{
accessorKey: "category",
header: "Category",
cell: ({ row }) => (
<Badge variant="outline" className="text-xs">
{row.original.category}
</Badge>
),
},
{
accessorKey: "duration",
header: "Duration",
cell: ({ row }) => `${row.original.duration} mins`,
},
{
accessorKey: "price",
header: () => <div className="text-right">Price</div>,
cell: ({ row }) => (
<div className="text-right font-medium">
{row.original.price || "N/A"}
</div>
),
},
{
accessorKey: "bookings",
header: "Bookings",
cell: ({ row }) => row.original.appointments.length,
},
{
accessorKey: "isActive",
header: "Status",
cell: ({ row }) => (
<Badge
variant={row.original.isActive ? "default" : "secondary"}
className="text-xs"
>
{row.original.isActive ? "Active" : "Inactive"}
</Badge>
),
},
];
type AdminServicesTableProps = {
services: Service[];
};
export function AdminServicesTable({ services }: AdminServicesTableProps) {
const router = useRouter();
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
);
const [sorting, setSorting] = React.useState<SortingState>([]);
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
});
const [isLoading, setIsLoading] = React.useState(false);
const handleServiceAction = async (
action: () => Promise<{ success: boolean; message: string }>,
actionName: string
) => {
setIsLoading(true);
try {
const result = await action();
if (result.success) {
toast.success(result.message);
router.refresh();
} else {
toast.error(result.message);
}
} catch (error) {
toast.error(`Failed to ${actionName}`);
console.error(error);
} finally {
setIsLoading(false);
}
};
const handleBulkAction = async (
action: (ids: string[]) => Promise<{ success: boolean; message: string }>,
actionName: string
) => {
const selectedRows = table.getFilteredSelectedRowModel().rows;
const ids = selectedRows.map((row) => row.original.id);
if (ids.length === 0) {
toast.error("No services selected");
return;
}
setIsLoading(true);
try {
const result = await action(ids);
if (result.success) {
toast.success(result.message);
setRowSelection({});
router.refresh();
} else {
toast.error(result.message);
}
} catch (error) {
toast.error(`Failed to ${actionName}`);
console.error(error);
} finally {
setIsLoading(false);
}
};
const actionsColumn: ColumnDef<Service> = {
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
disabled={isLoading}
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onClick={() => toast.info("Edit feature coming soon")}
>
Edit Service
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleServiceAction(
() => duplicateService(row.original.id),
"duplicate service"
)
}
>
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
handleServiceAction(
() =>
updateServiceStatus(
[row.original.id],
!row.original.isActive
),
"toggle service status"
)
}
>
{row.original.isActive ? "Deactivate" : "Activate"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() =>
handleServiceAction(
() => deleteService(row.original.id),
"delete service"
)
}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
};
const columnsWithActions = [...columns, actionsColumn];
const table = useReactTable({
data: services,
columns: columnsWithActions,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
});
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="relative w-full max-w-sm">
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
<Input
placeholder="Search services..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("name")?.setFilterValue(event.target.value)
}
className="pl-8"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Bulk Actions Toolbar */}
{table.getFilteredSelectedRowModel().rows.length > 0 && (
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
<span className="text-sm font-medium">
{table.getFilteredSelectedRowModel().rows.length} selected
</span>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={isLoading}
onClick={() =>
handleBulkAction(
(ids) => updateServiceStatus(ids, true),
"activate services"
)
}
>
Activate Selected
</Button>
<Button
variant="outline"
size="sm"
disabled={isLoading}
onClick={() =>
handleBulkAction(
(ids) => updateServiceStatus(ids, false),
"deactivate services"
)
}
>
Deactivate Selected
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={isLoading}>
More Actions
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => toast.info("Duplicate feature coming soon")}
>
Duplicate Selected
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.info("Update prices feature coming soon")
}
>
Update Prices
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toast.info("Export feature coming soon")}
>
Export Selected
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() =>
handleBulkAction(deleteServices, "delete services")
}
>
Delete Selected
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader className="bg-muted">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No services found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,749 @@
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
IconBell,
IconBriefcase,
IconKey,
IconShield,
IconUser,
} from "@tabler/icons-react";
import { toast } from "sonner";
import {
getAdminSettings,
updateAdminSettings,
} from "@/lib/actions/settings-actions";
type User = {
id: string;
name: string;
email: string;
role?: string;
};
type AdminSettingsContentProps = {
user: User;
};
export function AdminSettingsContent({}: AdminSettingsContentProps) {
const [isLoading, setIsLoading] = React.useState(false);
// General Settings State
const [clinicName, setClinicName] = React.useState("Dental U-Care");
const [clinicEmail, setClinicEmail] = React.useState("info@dentalucare.com");
const [clinicPhone, setClinicPhone] = React.useState("+1 (555) 123-4567");
const [clinicAddress, setClinicAddress] = React.useState(
"123 Medical Plaza, Suite 100"
);
const [timezone, setTimezone] = React.useState("America/New_York");
// Appointment Settings State
const [appointmentDuration, setAppointmentDuration] = React.useState("60");
const [bufferTime, setBufferTime] = React.useState("15");
const [maxAdvanceBooking, setMaxAdvanceBooking] = React.useState("90");
const [cancellationDeadline, setCancellationDeadline] = React.useState("24");
const [autoConfirmAppointments, setAutoConfirmAppointments] =
React.useState(false);
// Notification Settings State
const [emailNotifications, setEmailNotifications] = React.useState(true);
const [smsNotifications, setSmsNotifications] = React.useState(true);
const [appointmentReminders, setAppointmentReminders] = React.useState(true);
const [reminderHoursBefore, setReminderHoursBefore] = React.useState("24");
const [newBookingNotifications, setNewBookingNotifications] =
React.useState(true);
const [cancellationNotifications, setCancellationNotifications] =
React.useState(true);
// Payment Settings State
const [requirePaymentUpfront, setRequirePaymentUpfront] =
React.useState(false);
const [allowPartialPayment, setAllowPartialPayment] = React.useState(true);
const [depositPercentage, setDepositPercentage] = React.useState("50");
const [acceptCash, setAcceptCash] = React.useState(true);
const [acceptCard, setAcceptCard] = React.useState(true);
const [acceptEWallet, setAcceptEWallet] = React.useState(true);
// Security Settings State
const [twoFactorAuth, setTwoFactorAuth] = React.useState(false);
const [sessionTimeout, setSessionTimeout] = React.useState("60");
const [passwordExpiry, setPasswordExpiry] = React.useState("90");
const [loginAttempts, setLoginAttempts] = React.useState("5");
// Load settings on mount
React.useEffect(() => {
const loadSettings = async () => {
try {
const settings = await getAdminSettings();
if (settings) {
setClinicName(settings.clinicName);
setClinicEmail(settings.clinicEmail);
setClinicPhone(settings.clinicPhone);
setClinicAddress(settings.clinicAddress);
setTimezone(settings.timezone);
setAppointmentDuration(settings.appointmentDuration);
setBufferTime(settings.bufferTime);
setMaxAdvanceBooking(settings.maxAdvanceBooking);
setCancellationDeadline(settings.cancellationDeadline);
setAutoConfirmAppointments(settings.autoConfirmAppointments);
setEmailNotifications(settings.emailNotifications);
setSmsNotifications(settings.smsNotifications);
setAppointmentReminders(settings.appointmentReminders);
setReminderHoursBefore(settings.reminderHoursBefore);
setNewBookingNotifications(settings.newBookingNotifications);
setCancellationNotifications(settings.cancellationNotifications);
setRequirePaymentUpfront(settings.requirePaymentUpfront);
setAllowPartialPayment(settings.allowPartialPayment);
setDepositPercentage(settings.depositPercentage);
setAcceptCash(settings.acceptCash);
setAcceptCard(settings.acceptCard);
setAcceptEWallet(settings.acceptEWallet);
setTwoFactorAuth(settings.twoFactorAuth);
setSessionTimeout(settings.sessionTimeout);
setPasswordExpiry(settings.passwordExpiry);
setLoginAttempts(settings.loginAttempts);
}
} catch (error) {
console.error("Failed to load admin settings:", error);
toast.error("Failed to load settings");
}
};
loadSettings();
}, []);
const handleSaveGeneral = async () => {
setIsLoading(true);
try {
const result = await updateAdminSettings({
clinicName,
clinicEmail,
clinicPhone,
clinicAddress,
timezone,
});
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
} catch (error) {
toast.error("Failed to save general settings");
console.error(error);
} finally {
setIsLoading(false);
}
};
const handleSaveAppointments = async () => {
setIsLoading(true);
try {
const result = await updateAdminSettings({
appointmentDuration,
bufferTime,
maxAdvanceBooking,
cancellationDeadline,
autoConfirmAppointments,
});
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
} catch (error) {
toast.error("Failed to save appointment settings");
console.error(error);
} finally {
setIsLoading(false);
}
};
const handleSaveNotifications = async () => {
setIsLoading(true);
try {
const result = await updateAdminSettings({
emailNotifications,
smsNotifications,
appointmentReminders,
reminderHoursBefore,
newBookingNotifications,
cancellationNotifications,
});
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
} catch (error) {
toast.error("Failed to save notification settings");
console.error(error);
} finally {
setIsLoading(false);
}
};
const handleSavePayments = async () => {
setIsLoading(true);
try {
const result = await updateAdminSettings({
requirePaymentUpfront,
allowPartialPayment,
depositPercentage,
acceptCash,
acceptCard,
acceptEWallet,
});
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
} catch (error) {
toast.error("Failed to save payment settings");
console.error(error);
} finally {
setIsLoading(false);
}
};
const handleSaveSecurity = async () => {
setIsLoading(true);
try {
const result = await updateAdminSettings({
twoFactorAuth,
sessionTimeout,
passwordExpiry,
loginAttempts,
});
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
} catch (error) {
toast.error("Failed to save security settings");
console.error(error);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Admin Settings</h1>
<p className="text-sm text-muted-foreground">
Manage your clinic settings and preferences
</p>
</div>
</div>
<Tabs defaultValue="general" className="space-y-4">
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-5">
<TabsTrigger value="general" className="gap-2">
<IconBriefcase className="size-4" />
<span className="hidden sm:inline">General</span>
</TabsTrigger>
<TabsTrigger value="appointments" className="gap-2">
<IconUser className="size-4" />
<span className="hidden sm:inline">Appointments</span>
</TabsTrigger>
<TabsTrigger value="notifications" className="gap-2">
<IconBell className="size-4" />
<span className="hidden sm:inline">Notifications</span>
</TabsTrigger>
<TabsTrigger value="payments" className="gap-2">
<IconKey className="size-4" />
<span className="hidden sm:inline">Payments</span>
</TabsTrigger>
<TabsTrigger value="security" className="gap-2">
<IconShield className="size-4" />
<span className="hidden sm:inline">Security</span>
</TabsTrigger>
</TabsList>
{/* General Settings */}
<TabsContent value="general" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Clinic Information</CardTitle>
<CardDescription>
Update your clinic&apos;s basic information and contact details
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="clinic-name">Clinic Name</Label>
<Input
id="clinic-name"
value={clinicName}
onChange={(e) => setClinicName(e.target.value)}
placeholder="Dental U-Care"
/>
</div>
<div className="space-y-2">
<Label htmlFor="clinic-email">Email Address</Label>
<Input
id="clinic-email"
type="email"
value={clinicEmail}
onChange={(e) => setClinicEmail(e.target.value)}
placeholder="info@dentalucare.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="clinic-phone">Phone Number</Label>
<Input
id="clinic-phone"
value={clinicPhone}
onChange={(e) => setClinicPhone(e.target.value)}
placeholder="+1 (555) 123-4567"
/>
</div>
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
<Select value={timezone} onValueChange={setTimezone}>
<SelectTrigger id="timezone">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="America/New_York">
Eastern Time (ET)
</SelectItem>
<SelectItem value="America/Chicago">
Central Time (CT)
</SelectItem>
<SelectItem value="America/Denver">
Mountain Time (MT)
</SelectItem>
<SelectItem value="America/Los_Angeles">
Pacific Time (PT)
</SelectItem>
<SelectItem value="Asia/Manila">
Philippine Time (PHT)
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="clinic-address">Clinic Address</Label>
<Textarea
id="clinic-address"
value={clinicAddress}
onChange={(e) => setClinicAddress(e.target.value)}
placeholder="123 Medical Plaza, Suite 100"
rows={3}
/>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveGeneral} disabled={isLoading}>
{isLoading ? "Saving..." : "Save Changes"}
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Appointment Settings */}
<TabsContent value="appointments" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Appointment Configuration</CardTitle>
<CardDescription>
Manage appointment durations, booking limits, and scheduling
rules
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="appointment-duration">
Default Duration (minutes)
</Label>
<Select
value={appointmentDuration}
onValueChange={setAppointmentDuration}
>
<SelectTrigger id="appointment-duration">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="30">30 minutes</SelectItem>
<SelectItem value="45">45 minutes</SelectItem>
<SelectItem value="60">60 minutes</SelectItem>
<SelectItem value="90">90 minutes</SelectItem>
<SelectItem value="120">120 minutes</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="buffer-time">Buffer Time (minutes)</Label>
<Select value={bufferTime} onValueChange={setBufferTime}>
<SelectTrigger id="buffer-time">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">No buffer</SelectItem>
<SelectItem value="10">10 minutes</SelectItem>
<SelectItem value="15">15 minutes</SelectItem>
<SelectItem value="20">20 minutes</SelectItem>
<SelectItem value="30">30 minutes</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="max-advance">
Maximum Advance Booking (days)
</Label>
<Input
id="max-advance"
type="number"
value={maxAdvanceBooking}
onChange={(e) => setMaxAdvanceBooking(e.target.value)}
min="1"
max="365"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cancellation-deadline">
Cancellation Deadline (hours)
</Label>
<Input
id="cancellation-deadline"
type="number"
value={cancellationDeadline}
onChange={(e) => setCancellationDeadline(e.target.value)}
min="1"
max="72"
/>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Auto-confirm Appointments</Label>
<p className="text-sm text-muted-foreground">
Automatically confirm new bookings without manual review
</p>
</div>
<Switch
checked={autoConfirmAppointments}
onCheckedChange={setAutoConfirmAppointments}
/>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveAppointments} disabled={isLoading}>
{isLoading ? "Saving..." : "Save Changes"}
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Notification Settings */}
<TabsContent value="notifications" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Notification Preferences</CardTitle>
<CardDescription>
Configure email, SMS, and reminder notifications
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Email Notifications</Label>
<p className="text-sm text-muted-foreground">
Receive notifications via email
</p>
</div>
<Switch
checked={emailNotifications}
onCheckedChange={setEmailNotifications}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>SMS Notifications</Label>
<p className="text-sm text-muted-foreground">
Receive notifications via SMS
</p>
</div>
<Switch
checked={smsNotifications}
onCheckedChange={setSmsNotifications}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Appointment Reminders</Label>
<p className="text-sm text-muted-foreground">
Send reminders to patients before appointments
</p>
</div>
<Switch
checked={appointmentReminders}
onCheckedChange={setAppointmentReminders}
/>
</div>
{appointmentReminders && (
<div className="ml-6 space-y-2">
<Label htmlFor="reminder-hours">
Send reminder (hours before)
</Label>
<Select
value={reminderHoursBefore}
onValueChange={setReminderHoursBefore}
>
<SelectTrigger id="reminder-hours">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="2">2 hours</SelectItem>
<SelectItem value="4">4 hours</SelectItem>
<SelectItem value="12">12 hours</SelectItem>
<SelectItem value="24">24 hours</SelectItem>
<SelectItem value="48">48 hours</SelectItem>
</SelectContent>
</Select>
</div>
)}
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>New Booking Notifications</Label>
<p className="text-sm text-muted-foreground">
Get notified when new appointments are booked
</p>
</div>
<Switch
checked={newBookingNotifications}
onCheckedChange={setNewBookingNotifications}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Cancellation Notifications</Label>
<p className="text-sm text-muted-foreground">
Get notified when appointments are cancelled
</p>
</div>
<Switch
checked={cancellationNotifications}
onCheckedChange={setCancellationNotifications}
/>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveNotifications} disabled={isLoading}>
{isLoading ? "Saving..." : "Save Changes"}
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Payment Settings */}
<TabsContent value="payments" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Payment Configuration</CardTitle>
<CardDescription>
Manage payment methods and billing preferences
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Require Payment Upfront</Label>
<p className="text-sm text-muted-foreground">
Require full payment when booking
</p>
</div>
<Switch
checked={requirePaymentUpfront}
onCheckedChange={setRequirePaymentUpfront}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Allow Partial Payment</Label>
<p className="text-sm text-muted-foreground">
Allow patients to pay a deposit
</p>
</div>
<Switch
checked={allowPartialPayment}
onCheckedChange={setAllowPartialPayment}
/>
</div>
{allowPartialPayment && (
<div className="ml-6 space-y-2">
<Label htmlFor="deposit-percentage">
Deposit Percentage
</Label>
<div className="relative">
<Input
id="deposit-percentage"
type="number"
value={depositPercentage}
onChange={(e) => setDepositPercentage(e.target.value)}
min="10"
max="100"
className="pr-8"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
%
</span>
</div>
</div>
)}
<Separator />
<div className="space-y-3">
<Label>Accepted Payment Methods</Label>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="font-normal">Cash</Label>
<Switch
checked={acceptCash}
onCheckedChange={setAcceptCash}
/>
</div>
<div className="flex items-center justify-between">
<Label className="font-normal">Credit/Debit Card</Label>
<Switch
checked={acceptCard}
onCheckedChange={setAcceptCard}
/>
</div>
<div className="flex items-center justify-between">
<Label className="font-normal">E-Wallet</Label>
<Switch
checked={acceptEWallet}
onCheckedChange={setAcceptEWallet}
/>
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSavePayments} disabled={isLoading}>
{isLoading ? "Saving..." : "Save Changes"}
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Security Settings */}
<TabsContent value="security" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Security & Privacy</CardTitle>
<CardDescription>
Manage security settings and access controls
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Two-Factor Authentication</Label>
<p className="text-sm text-muted-foreground">
Require 2FA for all admin accounts
</p>
</div>
<Switch
checked={twoFactorAuth}
onCheckedChange={setTwoFactorAuth}
/>
</div>
<Separator />
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="session-timeout">
Session Timeout (minutes)
</Label>
<Input
id="session-timeout"
type="number"
value={sessionTimeout}
onChange={(e) => setSessionTimeout(e.target.value)}
min="15"
max="480"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password-expiry">
Password Expiry (days)
</Label>
<Input
id="password-expiry"
type="number"
value={passwordExpiry}
onChange={(e) => setPasswordExpiry(e.target.value)}
min="30"
max="365"
/>
</div>
<div className="space-y-2">
<Label htmlFor="login-attempts">
Maximum Login Attempts
</Label>
<Input
id="login-attempts"
type="number"
value={loginAttempts}
onChange={(e) => setLoginAttempts(e.target.value)}
min="3"
max="10"
/>
</div>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveSecurity} disabled={isLoading}>
{isLoading ? "Saving..." : "Save Changes"}
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
"use client"
import * as React from "react"
import { type DateRange } from "react-day-picker"
import { enUS, es } from "react-day-picker/locale"
import { Calendar } from "@/components/ui/calendar"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const localizedStrings = {
en: {
title: "Book an appointment",
description: "Select the dates for your appointment",
},
es: {
title: "Reserva una cita",
description: "Selecciona las fechas para tu cita",
},
} as const
export default function Calendar12() {
const [locale, setLocale] =
React.useState<keyof typeof localizedStrings>("es")
const [dateRange, setDateRange] = React.useState<DateRange | undefined>({
from: new Date(2025, 8, 9),
to: new Date(2025, 8, 17),
})
return (
<Card>
<CardHeader className="border-b">
<CardTitle>{localizedStrings[locale].title}</CardTitle>
<CardDescription>
{localizedStrings[locale].description}
</CardDescription>
<CardAction>
<Select
value={locale}
onValueChange={(value) =>
setLocale(value as keyof typeof localizedStrings)
}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="es">Español</SelectItem>
<SelectItem value="en">English</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent>
<Calendar
mode="range"
selected={dateRange}
onSelect={setDateRange}
defaultMonth={dateRange?.from}
numberOfMonths={2}
locale={locale === "es" ? es : enUS}
className="bg-transparent p-0"
buttonVariant="outline"
/>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { Calendar } from "@/components/ui/calendar"
export default function Calendar14() {
const [date, setDate] = React.useState<Date | undefined>(
new Date(2025, 5, 12)
)
const bookedDates = Array.from(
{ length: 12 },
(_, i) => new Date(2025, 5, 15 + i)
)
return (
<Calendar
mode="single"
defaultMonth={date}
selected={date}
onSelect={setDate}
disabled={bookedDates}
modifiers={{
booked: bookedDates,
}}
modifiersClassNames={{
booked: "[&>button]:line-through opacity-100",
}}
className="rounded-lg border shadow-sm"
/>
)
}

View File

@@ -0,0 +1,297 @@
"use client";
import * as React from "react";
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import { useIsMobile } from "@/hooks/use-mobile";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
export const description = "An interactive area chart";
type ChartDataPoint = {
date: string;
appointments: number;
};
const defaultChartData: ChartDataPoint[] = [
{ date: "2024-04-01", appointments: 12 },
{ date: "2024-04-02", appointments: 8 },
{ date: "2024-04-03", appointments: 15 },
{ date: "2024-04-04", appointments: 22 },
{ date: "2024-04-05", appointments: 18 },
{ date: "2024-04-06", appointments: 25 },
{ date: "2024-04-07", appointments: 10 },
{ date: "2024-04-08", appointments: 16 },
{ date: "2024-04-09", appointments: 9 },
{ date: "2024-04-10", appointments: 19 },
{ date: "2024-04-11", appointments: 27 },
{ date: "2024-04-12", appointments: 21 },
{ date: "2024-04-13", appointments: 14 },
{ date: "2024-04-14", appointments: 11 },
{ date: "2024-04-15", appointments: 17 },
{ date: "2024-04-16", appointments: 13 },
{ date: "2024-04-17", appointments: 24 },
{ date: "2024-04-18", appointments: 28 },
{ date: "2024-04-19", appointments: 20 },
{ date: "2024-04-20", appointments: 12 },
{ date: "2024-04-21", appointments: 16 },
{ date: "2024-04-22", appointments: 19 },
{ date: "2024-04-23", appointments: 23 },
{ date: "2024-04-24", appointments: 26 },
{ date: "2024-04-25", appointments: 15 },
{ date: "2024-04-26", appointments: 8 },
{ date: "2024-04-27", appointments: 29 },
{ date: "2024-04-28", appointments: 18 },
{ date: "2024-04-29", appointments: 22 },
{ date: "2024-04-30", appointments: 25 },
{ date: "2024-05-01", appointments: 14 },
{ date: "2024-05-02", appointments: 20 },
{ date: "2024-05-03", appointments: 17 },
{ date: "2024-05-04", appointments: 24 },
{ date: "2024-05-05", appointments: 28 },
{ date: "2024-05-06", appointments: 30 },
{ date: "2024-05-07", appointments: 21 },
{ date: "2024-05-08", appointments: 16 },
{ date: "2024-05-09", appointments: 19 },
{ date: "2024-05-10", appointments: 23 },
{ date: "2024-05-11", appointments: 26 },
{ date: "2024-05-12", appointments: 18 },
{ date: "2024-05-13", appointments: 13 },
{ date: "2024-05-14", appointments: 27 },
{ date: "2024-05-15", appointments: 25 },
{ date: "2024-05-16", appointments: 22 },
{ date: "2024-05-17", appointments: 29 },
{ date: "2024-05-18", appointments: 24 },
{ date: "2024-05-19", appointments: 17 },
{ date: "2024-05-20", appointments: 20 },
{ date: "2024-05-21", appointments: 11 },
{ date: "2024-05-22", appointments: 10 },
{ date: "2024-05-23", appointments: 21 },
{ date: "2024-05-24", appointments: 19 },
{ date: "2024-05-25", appointments: 16 },
{ date: "2024-05-26", appointments: 14 },
{ date: "2024-05-27", appointments: 28 },
{ date: "2024-05-28", appointments: 18 },
{ date: "2024-05-29", appointments: 12 },
{ date: "2024-05-30", appointments: 23 },
{ date: "2024-05-31", appointments: 17 },
{ date: "2024-06-01", appointments: 15 },
{ date: "2024-06-02", appointments: 26 },
{ date: "2024-06-03", appointments: 13 },
{ date: "2024-06-04", appointments: 25 },
{ date: "2024-06-05", appointments: 11 },
{ date: "2024-06-06", appointments: 19 },
{ date: "2024-06-07", appointments: 22 },
{ date: "2024-06-08", appointments: 24 },
{ date: "2024-06-09", appointments: 27 },
{ date: "2024-06-10", appointments: 16 },
{ date: "2024-06-11", appointments: 10 },
{ date: "2024-06-12", appointments: 28 },
{ date: "2024-06-13", appointments: 12 },
{ date: "2024-06-14", appointments: 25 },
{ date: "2024-06-15", appointments: 21 },
{ date: "2024-06-16", appointments: 23 },
{ date: "2024-06-17", appointments: 29 },
{ date: "2024-06-18", appointments: 14 },
{ date: "2024-06-19", appointments: 20 },
{ date: "2024-06-20", appointments: 26 },
{ date: "2024-06-21", appointments: 17 },
{ date: "2024-06-22", appointments: 22 },
{ date: "2024-06-23", appointments: 30 },
{ date: "2024-06-24", appointments: 15 },
{ date: "2024-06-25", appointments: 16 },
{ date: "2024-06-26", appointments: 24 },
{ date: "2024-06-27", appointments: 27 },
{ date: "2024-06-28", appointments: 18 },
{ date: "2024-06-29", appointments: 13 },
{ date: "2024-06-30", appointments: 25 },
];
const chartConfig = {
appointments: {
label: "Appointments",
color: "var(--primary)",
},
} satisfies ChartConfig;
export function ChartAreaInteractive({ data }: { data?: ChartDataPoint[] }) {
const isMobile = useIsMobile();
const [timeRange, setTimeRange] = React.useState("90d");
// Use provided data or fallback to default
const chartData = React.useMemo(() => {
if (!data || data.length === 0) {
return defaultChartData;
}
// Fill in missing dates with 0 appointments
const referenceDate = new Date();
const filledData: ChartDataPoint[] = [];
// Create a map of existing data
const dataMap = new Map(data.map((item) => [item.date, item.appointments]));
// Generate data for last 90 days
for (let i = 89; i >= 0; i--) {
const date = new Date(referenceDate);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split("T")[0];
filledData.push({
date: dateStr,
appointments: dataMap.get(dateStr) || 0,
});
}
return filledData;
}, [data]);
React.useEffect(() => {
if (isMobile) {
setTimeRange("7d");
}
}, [isMobile]);
const filteredData = React.useMemo(() => {
const referenceDate = new Date();
let daysToSubtract = 90;
if (timeRange === "30d") {
daysToSubtract = 30;
} else if (timeRange === "7d") {
daysToSubtract = 7;
}
const startDate = new Date(referenceDate);
startDate.setDate(startDate.getDate() - daysToSubtract);
return chartData.filter((item) => {
const date = new Date(item.date);
return date >= startDate;
});
}, [chartData, timeRange]);
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>Appointment Trends</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Appointments over time
</span>
<span className="@[540px]/card:hidden">Appointments</span>
</CardDescription>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
>
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Select a value"
>
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">
Last 3 months
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Last 7 days
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={filteredData}>
<defs>
<linearGradient id="fillAppointments" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-appointments)"
stopOpacity={1.0}
/>
<stop
offset="95%"
stopColor="var(--color-appointments)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}}
indicator="dot"
/>
}
/>
<Area
dataKey="appointments"
type="natural"
fill="url(#fillAppointments)"
stroke="var(--color-appointments)"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,807 @@
"use client"
import * as React from "react"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
type UniqueIdentifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconCircleCheckFilled,
IconDotsVertical,
IconGripVertical,
IconLayoutColumns,
IconLoader,
IconPlus,
IconTrendingUp,
} from "@tabler/icons-react"
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
Row,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { toast } from "sonner"
import { z } from "zod"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { Checkbox } from "@/components/ui/checkbox"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
export const schema = z.object({
id: z.number(),
header: z.string(),
type: z.string(),
status: z.string(),
target: z.string(),
limit: z.string(),
reviewer: z.string(),
})
// Create a separate component for the drag handle
function DragHandle({ id }: { id: number }) {
const { attributes, listeners } = useSortable({
id,
})
return (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="icon"
className="text-muted-foreground size-7 hover:bg-transparent"
>
<IconGripVertical className="text-muted-foreground size-3" />
<span className="sr-only">Drag to reorder</span>
</Button>
)
}
const columns: ColumnDef<z.infer<typeof schema>>[] = [
{
id: "drag",
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
},
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "header",
header: "Header",
cell: ({ row }) => {
return <TableCellViewer item={row.original} />
},
enableHiding: false,
},
{
accessorKey: "type",
header: "Section Type",
cell: ({ row }) => (
<div className="w-32">
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.type}
</Badge>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.status === "Done" ? (
<IconCircleCheckFilled className="fill-green-500 dark:fill-green-400" />
) : (
<IconLoader />
)}
{row.original.status}
</Badge>
),
},
{
accessorKey: "target",
header: () => <div className="w-full text-right">Target</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-target`} className="sr-only">
Target
</Label>
<Input
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
defaultValue={row.original.target}
id={`${row.original.id}-target`}
/>
</form>
),
},
{
accessorKey: "limit",
header: () => <div className="w-full text-right">Limit</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-limit`} className="sr-only">
Limit
</Label>
<Input
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
defaultValue={row.original.limit}
id={`${row.original.id}-limit`}
/>
</form>
),
},
{
accessorKey: "reviewer",
header: "Reviewer",
cell: ({ row }) => {
const isAssigned = row.original.reviewer !== "Assign reviewer"
if (isAssigned) {
return row.original.reviewer
}
return (
<>
<Label htmlFor={`${row.original.id}-reviewer`} className="sr-only">
Reviewer
</Label>
<Select>
<SelectTrigger
className="w-38 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate"
size="sm"
id={`${row.original.id}-reviewer`}
>
<SelectValue placeholder="Assign reviewer" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
</SelectContent>
</Select>
</>
)
},
},
{
id: "actions",
cell: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Make a copy</DropdownMenuItem>
<DropdownMenuItem>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
})
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
data-dragging={isDragging}
ref={setNodeRef}
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
export function DataTable({
data: initialData,
}: {
data: z.infer<typeof schema>[]
}) {
const [data, setData] = React.useState(() => initialData)
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [sorting, setSorting] = React.useState<SortingState>([])
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
})
const sortableId = React.useId()
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
const dataIds = React.useMemo<UniqueIdentifier[]>(
() => data?.map(({ id }) => id) || [],
[data]
)
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
getRowId: (row) => row.id.toString(),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (active && over && active.id !== over.id) {
setData((data) => {
const oldIndex = dataIds.indexOf(active.id)
const newIndex = dataIds.indexOf(over.id)
return arrayMove(data, oldIndex, newIndex)
})
}
}
return (
<Tabs
defaultValue="outline"
className="w-full flex-col justify-start gap-6"
>
<div className="flex items-center justify-between px-4 lg:px-6">
<Label htmlFor="view-selector" className="sr-only">
View
</Label>
<Select defaultValue="outline">
<SelectTrigger
className="flex w-fit @4xl/main:hidden"
size="sm"
id="view-selector"
>
<SelectValue placeholder="Select a view" />
</SelectTrigger>
<SelectContent>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="past-performance">Past Performance</SelectItem>
<SelectItem value="key-personnel">Key Personnel</SelectItem>
<SelectItem value="focus-documents">Focus Documents</SelectItem>
</SelectContent>
</Select>
<TabsList className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
<TabsTrigger value="outline">Outline</TabsTrigger>
<TabsTrigger value="past-performance">
Past Performance <Badge variant="secondary">3</Badge>
</TabsTrigger>
<TabsTrigger value="key-personnel">
Key Personnel <Badge variant="secondary">2</Badge>
</TabsTrigger>
<TabsTrigger value="focus-documents">Focus Documents</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Customize Columns</span>
<span className="lg:hidden">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm">
<IconPlus />
<span className="hidden lg:inline">Add Section</span>
</Button>
</div>
</div>
<TabsContent
value="outline"
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
>
<div className="overflow-hidden rounded-lg border">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="bg-muted sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{table.getRowModel().rows?.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
<div className="flex items-center justify-between px-4">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</TabsContent>
<TabsContent
value="past-performance"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent
value="focus-documents"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
</Tabs>
)
}
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 },
]
const chartConfig = {
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
const isMobile = useIsMobile()
return (
<Drawer direction={isMobile ? "bottom" : "right"}>
<DrawerTrigger asChild>
<Button variant="link" className="text-foreground w-fit px-0 text-left">
{item.header}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="gap-1">
<DrawerTitle>{item.header}</DrawerTitle>
<DrawerDescription>
Showing total visitors for the last 6 months
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
{!isMobile && (
<>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={chartData}
margin={{
left: 0,
right: 10,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
hide
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="mobile"
type="natural"
fill="var(--color-mobile)"
fillOpacity={0.6}
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="var(--color-desktop)"
fillOpacity={0.4}
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
<Separator />
<div className="grid gap-2">
<div className="flex gap-2 leading-none font-medium">
Trending up by 5.2% this month{" "}
<IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Showing total visitors for the last 6 months. This is just
some random text to test the layout. It spans multiple lines
and should wrap around.
</div>
</div>
<Separator />
</>
)}
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="header">Header</Label>
<Input id="header" defaultValue={item.header} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="type">Type</Label>
<Select defaultValue={item.type}>
<SelectTrigger id="type" className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Table of Contents">
Table of Contents
</SelectItem>
<SelectItem value="Executive Summary">
Executive Summary
</SelectItem>
<SelectItem value="Technical Approach">
Technical Approach
</SelectItem>
<SelectItem value="Design">Design</SelectItem>
<SelectItem value="Capabilities">Capabilities</SelectItem>
<SelectItem value="Focus Documents">
Focus Documents
</SelectItem>
<SelectItem value="Narrative">Narrative</SelectItem>
<SelectItem value="Cover Page">Cover Page</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="status">Status</Label>
<Select defaultValue={item.status}>
<SelectTrigger id="status" className="w-full">
<SelectValue placeholder="Select a status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Done">Done</SelectItem>
<SelectItem value="In Progress">In Progress</SelectItem>
<SelectItem value="Not Started">Not Started</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="target">Target</Label>
<Input id="target" defaultValue={item.target} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="limit">Limit</Label>
<Input id="limit" defaultValue={item.limit} />
</div>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="reviewer">Reviewer</Label>
<Select defaultValue={item.reviewer}>
<SelectTrigger id="reviewer" className="w-full">
<SelectValue placeholder="Select a reviewer" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
<SelectItem value="Emily Whalen">Emily Whalen</SelectItem>
</SelectContent>
</Select>
</div>
</form>
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

View File

@@ -0,0 +1,316 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Calendar, Clock, Phone, Mail } from "lucide-react";
import { toast } from "sonner";
type Appointment = {
id: string;
date: Date;
timeSlot: string;
status: string;
notes: string | null;
patient: {
name: string;
email: string;
phone: string | null;
medicalHistory: string | null;
};
service: {
name: string;
duration: number;
price: number;
};
payment: {
status: string;
} | null;
};
type DentistAppointmentsListProps = {
appointments: Appointment[];
};
export function DentistAppointmentsList({
appointments,
}: DentistAppointmentsListProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState<string | null>(null);
const pendingAppointments = appointments.filter(
(apt) => apt.status === "pending"
);
const upcomingAppointments = appointments.filter(
(apt) => new Date(apt.date) >= new Date() && apt.status === "confirmed"
);
const completedAppointments = appointments.filter(
(apt) => apt.status === "completed"
);
const handleConfirmAppointment = async (appointmentId: string) => {
setIsLoading(appointmentId);
try {
const response = await fetch(`/api/appointments/${appointmentId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
status: "confirmed",
}),
});
if (!response.ok) {
throw new Error("Failed to confirm appointment");
}
toast.success("Appointment confirmed successfully");
router.refresh();
} catch (error) {
console.error(error);
toast.error("Failed to confirm appointment");
} finally {
setIsLoading(null);
}
};
const handleDeclineAppointment = async (appointmentId: string) => {
if (!confirm("Are you sure you want to decline this appointment?")) {
return;
}
setIsLoading(appointmentId);
try {
const response = await fetch(`/api/appointments/${appointmentId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
status: "cancelled",
cancelReason: "Declined by dentist",
}),
});
if (!response.ok) {
throw new Error("Failed to decline appointment");
}
toast.success("Appointment declined");
router.refresh();
} catch (error) {
console.error(error);
toast.error("Failed to decline appointment");
} finally {
setIsLoading(null);
}
};
const handleCompleteAppointment = async (appointmentId: string) => {
setIsLoading(appointmentId);
try {
const response = await fetch(`/api/appointments/${appointmentId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
status: "completed",
}),
});
if (!response.ok) {
throw new Error("Failed to complete appointment");
}
toast.success("Appointment marked as completed");
router.refresh();
} catch (error) {
console.error(error);
toast.error("Failed to complete appointment");
} finally {
setIsLoading(null);
}
};
const getStatusBadge = (status: string) => {
const variants: Record<
string,
"default" | "secondary" | "destructive" | "outline"
> = {
pending: "secondary",
confirmed: "default",
cancelled: "destructive",
completed: "outline",
rescheduled: "secondary",
};
return (
<Badge variant={variants[status] || "default"}>
{status.toUpperCase()}
</Badge>
);
};
const renderAppointmentCard = (
appointment: Appointment,
showActions: boolean = true
) => (
<Card key={appointment.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">
{appointment.patient.name}
</CardTitle>
<CardDescription className="mt-1">
{appointment.service.name}
</CardDescription>
</div>
{getStatusBadge(appointment.status)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>{new Date(appointment.date).toLocaleDateString()}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span>{appointment.timeSlot}</span>
</div>
{appointment.patient.phone && (
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-muted-foreground" />
<span>{appointment.patient.phone}</span>
</div>
)}
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
<span className="truncate">{appointment.patient.email}</span>
</div>
</div>
{appointment.patient.medicalHistory && (
<div className="text-sm">
<p className="font-medium">Medical History:</p>
<p className="text-muted-foreground">
{appointment.patient.medicalHistory}
</p>
</div>
)}
{appointment.notes && (
<div className="text-sm">
<p className="font-medium">Patient Notes:</p>
<p className="text-muted-foreground">{appointment.notes}</p>
</div>
)}
{showActions && (
<div className="flex gap-2">
{appointment.status === "pending" && (
<>
<Button
size="sm"
className="flex-1"
onClick={() => handleConfirmAppointment(appointment.id)}
disabled={isLoading === appointment.id}
>
{isLoading === appointment.id ? "Confirming..." : "Confirm"}
</Button>
<Button
variant="destructive"
size="sm"
className="flex-1"
onClick={() => handleDeclineAppointment(appointment.id)}
disabled={isLoading === appointment.id}
>
Decline
</Button>
</>
)}
{appointment.status === "confirmed" && (
<Button
size="sm"
className="w-full"
onClick={() => handleCompleteAppointment(appointment.id)}
disabled={isLoading === appointment.id}
>
{isLoading === appointment.id
? "Completing..."
: "Mark as Completed"}
</Button>
)}
</div>
)}
</CardContent>
</Card>
);
return (
<Tabs defaultValue="pending" className="w-full">
<TabsList className="grid w-full max-w-2xl grid-cols-3">
<TabsTrigger value="pending">
Pending ({pendingAppointments.length})
</TabsTrigger>
<TabsTrigger value="upcoming">
Upcoming ({upcomingAppointments.length})
</TabsTrigger>
<TabsTrigger value="completed">
Completed ({completedAppointments.length})
</TabsTrigger>
</TabsList>
<TabsContent value="pending" className="space-y-4 mt-6">
{pendingAppointments.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No pending appointments</p>
</CardContent>
</Card>
) : (
pendingAppointments.map((apt) => renderAppointmentCard(apt))
)}
</TabsContent>
<TabsContent value="upcoming" className="space-y-4 mt-6">
{upcomingAppointments.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No upcoming appointments</p>
</CardContent>
</Card>
) : (
upcomingAppointments.map((apt) => renderAppointmentCard(apt))
)}
</TabsContent>
<TabsContent value="completed" className="space-y-4 mt-6">
{completedAppointments.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No completed appointments</p>
</CardContent>
</Card>
) : (
completedAppointments.map((apt) => renderAppointmentCard(apt, false))
)}
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,139 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Search, Mail, Phone } from "lucide-react"
type Patient = {
id: string
name: string
email: string
phone: string | null
medicalHistory: string | null
appointments: Array<{
id: string
date: Date
status: string
service: {
name: string
}
}>
}
type DentistPatientsTableProps = {
patients: Patient[]
}
export function DentistPatientsTable({ patients }: DentistPatientsTableProps) {
const [searchQuery, setSearchQuery] = useState("")
const filteredPatients = patients.filter((patient) => {
const query = searchQuery.toLowerCase()
return (
patient.name.toLowerCase().includes(query) ||
patient.email.toLowerCase().includes(query)
)
})
return (
<Card>
<CardHeader>
<CardTitle>My Patients</CardTitle>
<CardDescription>
Total: {patients.length} patients
</CardDescription>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Patient Name</TableHead>
<TableHead>Contact</TableHead>
<TableHead>Total Visits</TableHead>
<TableHead>Last Visit</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPatients.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No patients found
</TableCell>
</TableRow>
) : (
filteredPatients.map((patient) => {
const completedVisits = patient.appointments.filter(
(apt) => apt.status === "completed"
).length
const lastVisit = patient.appointments[0]
return (
<TableRow key={patient.id}>
<TableCell className="font-medium">{patient.name}</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-1 text-sm">
<Mail className="h-3 w-3" />
<span className="text-xs">{patient.email}</span>
</div>
{patient.phone && (
<div className="flex items-center gap-1 text-sm">
<Phone className="h-3 w-3" />
<span className="text-xs">{patient.phone}</span>
</div>
)}
</div>
</TableCell>
<TableCell>
<div>
<p>{patient.appointments.length} total</p>
<p className="text-xs text-muted-foreground">{completedVisits} completed</p>
</div>
</TableCell>
<TableCell>
{lastVisit ? (
<div>
<p>{new Date(lastVisit.date).toLocaleDateString()}</p>
<p className="text-xs text-muted-foreground">{lastVisit.service.name}</p>
</div>
) : (
"-"
)}
</TableCell>
<TableCell>
<Button variant="outline" size="sm">
View History
</Button>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,358 @@
import * as React from "react";
export interface ServiceItem {
description: string;
qty: number;
unitPrice: number;
total: number;
}
export interface DentalInvoiceProps {
invoiceNumber: string;
invoiceDate: string;
dueDate: string;
patientName: string;
patientAddress: string;
patientCity: string;
patientPhone: string;
patientEmail: string;
bookingId: string;
appointmentDate: string;
appointmentTime: string;
doctorName: string;
treatmentRoom: string;
appointmentDuration: string;
reasonForVisit: string;
pdfDownloadUrl: string;
paymentStatus: string;
nextAppointmentDate: string;
nextAppointmentTime: string;
nextAppointmentPurpose: string;
services: ServiceItem[];
subtotal: number;
tax: number;
totalDue: number;
}
import {
Html,
Head,
Body,
Container,
Section,
Row,
Column,
Text,
Heading,
Hr,
Button,
Tailwind,
} from "@react-email/components";
const DentalInvoice: React.FC<DentalInvoiceProps> = (props) => {
return (
<Html lang="en" dir="ltr">
<Tailwind>
<Head />
<Body className="bg-gray-100 font-sans py-[40px]">
<Container className="bg-white max-w-[600px] mx-auto rounded-[8px] shadow-lg">
{/* Header */}
<Section className="bg-blue-600 text-white p-[32px] rounded-t-[8px]">
<Heading className="text-[28px] font-bold m-0 text-center">
DENTAL U CARE
</Heading>
<Text className="text-[16px] text-center m-0 mt-[8px] opacity-90">
Professional Dental Services
</Text>
</Section>
{/* Invoice Header */}
<Section className="p-[32px]">
<Row>
<Column>
<Heading className="text-[24px] font-bold text-gray-800 m-0">
INVOICE
</Heading>
<Text className="text-[14px] text-gray-600 m-0 mt-[4px]">
Invoice #: {props.invoiceNumber}
</Text>
<Text className="text-[14px] text-gray-600 m-0">
Date: {props.invoiceDate}
</Text>
<Text className="text-[14px] text-gray-600 m-0">
Due Date: {props.dueDate}
</Text>
</Column>
<Column align="right">
<Text className="text-[14px] text-gray-600 m-0 font-semibold">
Dental U Care Clinic
</Text>
<Text className="text-[14px] text-gray-600 m-0">
Baltan Street
</Text>
<Text className="text-[14px] text-gray-600 m-0">
Puerto Princesa City, Palawan 5300
</Text>
<Text className="text-[14px] text-gray-600 m-0">
Phone: (043) 756-1234
</Text>
</Column>
</Row>
</Section>
<Hr className="border-gray-200 mx-[32px]" />
{/* Patient Information */}
<Section className="px-[32px] py-[24px]">
<Heading className="text-[18px] font-semibold text-gray-800 m-0 mb-[16px]">
Bill To:
</Heading>
<Text className="text-[14px] text-gray-700 m-0 font-semibold">
{props.patientName}
</Text>
<Text className="text-[14px] text-gray-600 m-0">
{props.patientAddress}
</Text>
<Text className="text-[14px] text-gray-600 m-0">
{props.patientCity}
</Text>
<Text className="text-[14px] text-gray-600 m-0">
Phone: {props.patientPhone}
</Text>
<Text className="text-[14px] text-gray-600 m-0">
Email: {props.patientEmail}
</Text>
</Section>
<Hr className="border-gray-200 mx-[32px]" />
{/* Booking Details */}
<Section className="px-[32px] py-[24px] bg-blue-50 mx-[32px] rounded-[8px]">
<Heading className="text-[18px] font-semibold text-gray-800 m-0 mb-[16px]">
Appointment Details:
</Heading>
<Row>
<Column className="w-[50%]">
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
<strong>Booking ID:</strong> {props.bookingId}
</Text>
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
<strong>Appointment Date:</strong> {props.appointmentDate}
</Text>
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
<strong>Time:</strong> {props.appointmentTime}
</Text>
</Column>
<Column className="w-[50%]">
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
<strong>Doctor:</strong> {props.doctorName}
</Text>
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
<strong>Treatment Room:</strong> {props.treatmentRoom}
</Text>
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
<strong>Duration:</strong> {props.appointmentDuration}
</Text>
</Column>
</Row>
<Text className="text-[14px] text-gray-700 m-0 mt-[12px]">
<strong>Reason for Visit:</strong> {props.reasonForVisit}
</Text>
</Section>
<Hr className="border-gray-200 mx-[32px] my-[24px]" />
{/* Services Table */}
<Section className="px-[32px] py-[24px]">
<Heading className="text-[18px] font-semibold text-gray-800 m-0 mb-[16px]">
Services Rendered:
</Heading>
{/* Table Header */}
<Row className="bg-gray-50 border-solid border-[1px] border-gray-200">
<Column className="p-[12px] w-[50%]">
<Text className="text-[14px] font-semibold text-gray-700 m-0">
Description
</Text>
</Column>
<Column className="p-[12px] w-[15%] text-center">
<Text className="text-[14px] font-semibold text-gray-700 m-0">
Qty
</Text>
</Column>
<Column className="p-[12px] w-[20%] text-center">
<Text className="text-[14px] font-semibold text-gray-700 m-0">
Unit Price
</Text>
</Column>
<Column className="p-[12px] w-[15%] text-center">
<Text className="text-[14px] font-semibold text-gray-700 m-0">
Total
</Text>
</Column>
</Row>
{/* Dynamic Service Items */}
{props.services.map((service, index) => (
<Row
key={index}
className="border-solid border-[1px] border-t-0 border-gray-200"
>
<Column className="p-[12px] w-[50%]">
<Text className="text-[14px] text-gray-700 m-0">
{service.description}
</Text>
</Column>
<Column className="p-[12px] w-[15%] text-center">
<Text className="text-[14px] text-gray-700 m-0">
{service.qty}
</Text>
</Column>
<Column className="p-[12px] w-[20%] text-center">
<Text className="text-[14px] text-gray-700 m-0">
{service.unitPrice.toFixed(2)}
</Text>
</Column>
<Column className="p-[12px] w-[15%] text-center">
<Text className="text-[14px] text-gray-700 m-0">
{service.total.toFixed(2)}
</Text>
</Column>
</Row>
))}
</Section>
{/* Totals */}
<Section className="px-[32px]">
<Row>
<Column className="w-[70%]"></Column>
<Column className="w-[30%]">
<Row className="mb-[8px]">
<Column className="w-[60%]">
<Text className="text-[14px] text-gray-700 m-0">
Subtotal:
</Text>
</Column>
<Column className="w-[40%] text-right">
<Text className="text-[14px] text-gray-700 m-0">
{props.subtotal.toFixed(2)}
</Text>
</Column>
</Row>
<Row className="mb-[8px]">
<Column className="w-[60%]">
<Text className="text-[14px] text-gray-700 m-0">
Tax (12%):
</Text>
</Column>
<Column className="w-[40%] text-right">
<Text className="text-[14px] text-gray-700 m-0">
{props.tax.toFixed(2)}
</Text>
</Column>
</Row>
<Hr className="border-gray-300 my-[8px]" />
<Row>
<Column className="w-[60%]">
<Text className="text-[16px] font-bold text-gray-800 m-0">
Total Due:
</Text>
</Column>
<Column className="w-[40%] text-right">
<Text className="text-[16px] font-bold text-blue-600 m-0">
{props.totalDue.toFixed(2)}
</Text>
</Column>
</Row>
</Column>
</Row>
</Section>
{/* Download PDF Button */}
<Section className="px-[32px] py-[24px] text-center">
<Button
href={props.pdfDownloadUrl}
className="box-border bg-blue-600 text-white px-[32px] py-[16px] rounded-[8px] text-[16px] font-semibold no-underline inline-block"
>
📄 Download PDF Invoice
</Button>
<Text className="text-[12px] text-gray-500 m-0 mt-[12px]">
Click the button above to download a PDF copy of this invoice
</Text>
</Section>
{/* Payment Information */}
<Section className="px-[32px] py-[24px] bg-green-50 mx-[32px] my-[24px] rounded-[8px]">
<Heading className="text-[16px] font-semibold text-gray-800 m-0 mb-[12px]">
Payment Information
</Heading>
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
<strong>Payment Status:</strong> {props.paymentStatus}
</Text>
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
<strong>Payment Methods:</strong> Cash, Credit Card, Bank
Transfer, GCash, PayMaya
</Text>
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
<strong>Bank Details:</strong> BPI - Account #1234567890 (Dental
U Care Clinic)
</Text>
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
<strong>GCash:</strong> 09171234567
</Text>
<Text className="text-[14px] text-gray-700 m-0">
<strong>Payment Terms:</strong> Payment due within 30 days of
invoice date
</Text>
</Section>
{/* Next Appointment */}
<Section className="px-[32px] py-[24px] bg-yellow-50 mx-[32px] rounded-[8px]">
<Heading className="text-[16px] font-semibold text-gray-800 m-0 mb-[12px]">
Next Appointment Reminder
</Heading>
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
<strong>Follow-up Date:</strong> {props.nextAppointmentDate}
</Text>
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
<strong>Time:</strong> {props.nextAppointmentTime}
</Text>
<Text className="text-[14px] text-gray-700 m-0">
<strong>Purpose:</strong> {props.nextAppointmentPurpose}
</Text>
</Section>
{/* Footer */}
<Section className="px-[32px] py-[24px] text-center border-t-[1px] border-solid border-gray-200">
<Text className="text-[14px] text-gray-600 m-0 mb-[8px]">
Thank you for choosing Dental U Care for your oral health needs!
</Text>
<Text className="text-[12px] text-gray-500 m-0 mb-[8px]">
For questions about this invoice, please contact us at
billing@dentalucare.com or (043) 756-1234
</Text>
<Text className="text-[12px] text-gray-500 m-0">
To reschedule or cancel appointments, call us at least 24 hours
in advance
</Text>
</Section>
{/* Company Footer */}
<Section className="px-[32px] py-[16px] bg-gray-50 text-center rounded-b-[8px]">
<Text className="text-[12px] text-gray-500 m-0">
Dental U Care Clinic | Baltan Street, Puerto Princesa City, Palawan
5300
</Text>
<Text className="text-[12px] text-gray-500 m-0">
© 2025 Dental U Care. All rights reserved. | License
#DC-2025-001
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default DentalInvoice;

View File

@@ -0,0 +1,198 @@
import * as React from "react";
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
Tailwind,
Row,
Column,
Hr,
} from "@react-email/components";
interface DentalAppointmentReminderProps {
patientName: string;
appointmentDate: string;
appointmentTime: string;
doctorName: string;
treatmentType: string;
duration: string;
clinicPhone: string;
clinicEmail: string;
clinicAddress: string;
}
interface DentalAppointmentReminderComponent
extends React.FC<DentalAppointmentReminderProps> {
PreviewProps?: DentalAppointmentReminderProps;
}
const DentalAppointmentReminder: DentalAppointmentReminderComponent = (
props: DentalAppointmentReminderProps
) => {
return (
<Html lang="en" dir="ltr">
<Tailwind>
<Head />
<Preview>
Your upcoming appointment at Dental U Care - {props.appointmentDate}
</Preview>
<Body className="bg-gray-100 font-sans py-[40px]">
<Container className="bg-white rounded-[8px] shadow-lg max-w-[600px] mx-auto p-[40px]">
{/* Header */}
<Section className="text-center mb-[32px]">
<Heading className="text-[28px] font-bold text-blue-600 m-0 mb-[8px]">
Dental U Care
</Heading>
<Text className="text-[16px] text-gray-600 m-0">
Your Smile, Our Priority
</Text>
</Section>
{/* Main Content */}
<Section className="mb-[32px]">
<Heading className="text-[24px] font-bold text-gray-800 mb-[16px]">
Appointment Reminder
</Heading>
<Text className="text-[16px] text-gray-700 mb-[24px] leading-[24px]">
Dear {props.patientName},
</Text>
<Text className="text-[16px] text-gray-700 mb-[24px] leading-[24px]">
This is a friendly reminder about your upcoming dental
appointment at Dental U Care.
</Text>
</Section>
{/* Appointment Details */}
<Section className="bg-blue-50 rounded-[8px] p-[24px] mb-[32px]">
<Heading className="text-[20px] font-bold text-blue-800 mb-[16px]">
Appointment Details
</Heading>
<Row className="mb-[12px]">
<Column className="w-[120px]">
<Text className="text-[14px] font-semibold text-gray-600 m-0">
Date:
</Text>
</Column>
<Column>
<Text className="text-[14px] text-gray-800 m-0">
{props.appointmentDate}
</Text>
</Column>
</Row>
<Row className="mb-[12px]">
<Column className="w-[120px]">
<Text className="text-[14px] font-semibold text-gray-600 m-0">
Time:
</Text>
</Column>
<Column>
<Text className="text-[14px] text-gray-800 m-0">
{props.appointmentTime}
</Text>
</Column>
</Row>
<Row className="mb-[12px]">
<Column className="w-[120px]">
<Text className="text-[14px] font-semibold text-gray-600 m-0">
Doctor:
</Text>
</Column>
<Column>
<Text className="text-[14px] text-gray-800 m-0">
{props.doctorName}
</Text>
</Column>
</Row>
<Row className="mb-[12px]">
<Column className="w-[120px]">
<Text className="text-[14px] font-semibold text-gray-600 m-0">
Treatment:
</Text>
</Column>
<Column>
<Text className="text-[14px] text-gray-800 m-0">
{props.treatmentType}
</Text>
</Column>
</Row>
<Row>
<Column className="w-[120px]">
<Text className="text-[14px] font-semibold text-gray-600 m-0">
Duration:
</Text>
</Column>
<Column>
<Text className="text-[14px] text-gray-800 m-0">
{props.duration}
</Text>
</Column>
</Row>
</Section>
{/* Important Notes */}
<Section className="mb-[32px]">
<Heading className="text-[18px] font-bold text-gray-800 mb-[16px]">
Important Reminders
</Heading>
<Text className="text-[14px] text-gray-700 mb-[8px] leading-[20px]">
Please arrive 10 minutes early for check-in
</Text>
<Text className="text-[14px] text-gray-700 mb-[8px] leading-[20px]">
Bring a valid ID and insurance card
</Text>
<Text className="text-[14px] text-gray-700 mb-[8px] leading-[20px]">
If you need to reschedule, please call us at least 24 hours in
advance
</Text>
<Text className="text-[14px] text-gray-700 mb-[8px] leading-[20px]">
Continue your regular oral hygiene routine before your visit
</Text>
</Section>
{/* Contact Information */}
<Section className="mb-[32px]">
<Heading className="text-[18px] font-bold text-gray-800 mb-[16px]">
Need to Make Changes?
</Heading>
<Text className="text-[16px] text-gray-700 mb-[16px] leading-[24px]">
If you need to reschedule or cancel your appointment, please
contact us:
</Text>
<Text className="text-[16px] text-blue-600 font-semibold mb-[8px]">
Phone: {props.clinicPhone}
</Text>
<Text className="text-[16px] text-blue-600 font-semibold mb-[16px]">
Email: {props.clinicEmail}
</Text>
</Section>
<Hr className="border-gray-300 mb-[32px]" />
{/* Footer */}
<Section className="text-center">
<Text className="text-[14px] text-gray-600 mb-[8px]">
Dental U Care
</Text>
<Text className="text-[14px] text-gray-600 mb-[8px] m-0">
{props.clinicAddress}
</Text>
<Text className="text-[14px] text-gray-600 mb-[16px]">
Phone: {props.clinicPhone} | Email: {props.clinicEmail}
</Text>
<Text className="text-[12px] text-gray-500 m-0">
© {new Date().getFullYear()} Dental U Care. All rights
reserved.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default DentalAppointmentReminder;

View File

@@ -0,0 +1,115 @@
import * as React from "react";
import {
Html,
Head,
Body,
Container,
Section,
Text,
Button,
Hr,
Tailwind,
} from "@react-email/components";
interface VerificationEmailProps {
username ?: string;
verificationUrl?: string;
}
const VerificationEmail = (props: VerificationEmailProps) => {
const { username, verificationUrl } = props;
return (
<Html lang="en" dir="ltr">
<Tailwind>
<Head />
<Body className="bg-gray-100 font-sans py-[40px]">
<Container className="bg-white rounded-[8px] shadow-lg max-w-[600px] mx-auto p-[40px]">
{/* Header */}
<Section className="text-center mb-[32px]">
<Text className="text-[32px] font-bold text-blue-600 m-0 mb-[8px]">
Dental U Care
</Text>
<Text className="text-[16px] text-gray-600 m-0">
Your Trusted Dental Care Partner
</Text>
</Section>
{/* Main Content */}
<Section>
<Text className="text-[24px] font-bold text-gray-800 mb-[24px] m-0">
Verify Your Email Address
</Text>
<Text className="text-[16px] text-gray-700 mb-[24px] m-0 leading-[24px]">
Thank you {username} for choosing Dental U Care! To complete your account
setup and ensure secure access to your dental care portal,
please verify your email address by clicking the button below.
</Text>
{/* Verification Button */}
<Section className="text-center my-[32px]">
<Button
href={verificationUrl}
className="bg-blue-600 text-white px-[32px] py-[16px] rounded-[8px] text-[16px] font-semibold no-underline box-border hover:bg-blue-700 transition-colors"
>
Verify Email Address
</Button>
</Section>
<Text className="text-[14px] text-gray-600 mb-[24px] m-0 leading-[20px]">
If the button above doesn`t work, you can also copy and paste
this link into your browser:
</Text>
<Text className="text-[14px] text-blue-600 mb-[32px] m-0 break-all">
{verificationUrl}
</Text>
<Text className="text-[16px] text-gray-700 mb-[24px] m-0 leading-[24px]">
This verification link will expire in 24 hours for your
security. If you didn`t create an account with Dental U Care,
please ignore this email.
</Text>
</Section>
<Hr className="border-gray-200 my-[32px]" />
{/* Footer */}
<Section>
<Text className="text-[14px] text-gray-600 mb-[16px] m-0">
Need help? Contact our support team at{" "}
<a
href="mailto:support@dentalucare.com"
className="text-blue-600 no-underline"
>
send@dentalucare.tech
</a>{" "}
or call us at (+63) 917-123-4567.
</Text>
<Text className="text-[12px] text-gray-500 m-0 mb-[8px]">
Dental U Care Clinic
</Text>
<Text className="text-[12px] text-gray-500 m-0 mb-[8px]">
Baltan Street, Barangay San Miguel
</Text>
<Text className="text-[12px] text-gray-500 m-0 mb-[16px]">
Puerto Princesa, Palawan, Philippines
</Text>
<Text className="text-[12px] text-gray-500 m-0">
© 2025 Dental U Care. All rights reserved.{" "}
<a href="#" className="text-blue-600 no-underline">
Unsubscribe
</a>
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default VerificationEmail;

View File

@@ -0,0 +1,134 @@
import * as React from "react";
import {
Html,
Head,
Body,
Container,
Section,
Text,
Button,
Hr,
Tailwind,
} from "@react-email/components";
interface ForgotPasswordEmailProps {
username?: string;
resetUrl?: string;
userEmail?: string;
}
const ForgotPasswordEmail = (props: ForgotPasswordEmailProps) => {
const { username, resetUrl, userEmail } = props;
return (
<Html lang="en" dir="ltr">
<Tailwind>
<Head />
<Body className="bg-gray-100 font-sans py-[40px]">
<Container className="bg-white rounded-[8px] shadow-lg max-w-[600px] mx-auto p-[40px]">
{/* Header */}
<Section className="text-center mb-[32px]">
<Text className="text-[32px] font-bold text-blue-600 m-0 mb-[8px]">
Dental U Care
</Text>
<Text className="text-[16px] text-gray-600 m-0">
Your Trusted Dental Care Partner
</Text>
</Section>
{/* Main Content */}
<Section>
<Text className="text-[24px] font-bold text-gray-800 mb-[24px] m-0">
Reset Your Password
</Text>
<Text className="text-[16px] text-gray-700 mb-[24px] m-0 leading-[24px]">
Hello {username}
</Text>
<Text className="text-[16px] text-gray-700 mb-[24px] m-0 leading-[24px]">
We received a request to reset the password for your {userEmail} Dental U
Care account. Don`t worry - it happens to the best of us! Click
the button below to create a new password.
</Text>
{/* Reset Password Button */}
<Section className="text-center my-[32px]">
<Button
href={resetUrl}
className="bg-green-600 text-white px-[32px] py-[16px] rounded-[8px] text-[16px] font-semibold no-underline box-border hover:bg-green-700 transition-colors"
>
Reset Password
</Button>
</Section>
<Text className="text-[14px] text-gray-600 mb-[24px] m-0 leading-[20px]">
If the button above doesn`t work, you can also copy and paste
this link into your browser:
</Text>
<Text className="text-[14px] text-blue-600 mb-[32px] m-0 break-all">
{resetUrl}
</Text>
<Section className="bg-yellow-50 border-l-[4px] border-yellow-400 p-[16px] mb-[24px] rounded-[4px]">
<Text className="text-[14px] text-yellow-800 m-0 font-semibold mb-[8px]">
Important Security Information:
</Text>
<Text className="text-[14px] text-yellow-700 m-0 leading-[20px]">
This reset link will expire in 1 hour for your security
<br />
If you didn`t request this password reset, please ignore
this email
<br />• Your current password will remain unchanged until you
create a new one
</Text>
</Section>
<Text className="text-[16px] text-gray-700 mb-[24px] m-0 leading-[24px]">
For your account security, we recommend choosing a strong
password that includes a mix of letters, numbers, and special
characters.
</Text>
</Section>
<Hr className="border-gray-200 my-[32px]" />
{/* Footer */}
<Section>
<Text className="text-[14px] text-gray-600 mb-[16px] m-0">
Having trouble? Our support team is here to help at{" "}
<a
href="mailto:info@dentalucare.com"
className="text-blue-600 no-underline"
>
info@dentalucare.com
</a>{" "}
or call us at (+63) 917-123-4567.
</Text>
<Text className="text-[12px] text-gray-500 m-0 mb-[8px]">
Dental U Care
</Text>
<Text className="text-[12px] text-gray-500 m-0 mb-[8px]">
Baltan Street, Barangay San Miguel
</Text>
<Text className="text-[12px] text-gray-500 m-0 mb-[16px]">
Puerto Princesa, Palawan, Philippines
</Text>
<Text className="text-[12px] text-gray-500 m-0">
© 2025 Dental U Care. All rights reserved.{" "}
<a href="#" className="text-blue-600 no-underline">
Unsubscribe
</a>
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default ForgotPasswordEmail;

View File

@@ -0,0 +1,169 @@
import { Button } from "@/components/ui/button";
import Image from "next/image";
interface About3Props {
title?: string;
description?: string;
mainImage?: {
src: string;
alt: string;
};
secondaryImage?: {
src: string;
alt: string;
};
breakout?: {
src: string;
alt: string;
title?: string;
description?: string;
buttonText?: string;
buttonUrl?: string;
};
companiesTitle?: string;
companies?: Array<{
src: string;
alt: string;
}>;
achievementsTitle?: string;
achievementsDescription?: string;
achievements?: Array<{
label: string;
value: string;
}>;
}
const defaultCompanies = [
{
src: "/tooth.svg",
alt: "tooth",
},
];
const defaultAchievements = [
{ label: "Happy Patients", value: "500+" },
{ label: "Appointments Booked", value: "1000+" },
{ label: "Satisfaction Rate", value: "98%" },
{ label: "Expert Dentists", value: "4" },
];
const About = ({
title = "About Dental U Care",
description = "We're a modern dental care provider committed to making quality dental services accessible through our innovative online appointment system. Experience hassle-free booking and world-class dental care.",
mainImage = {
src: "/clinic.jpg",
alt: "Modern dental clinic interior",
},
secondaryImage = {
src: "/team.jpg",
alt: "Professional dental team",
},
breakout = {
src: "/tooth.svg",
alt: "Dental U Care Logo",
title: "Book Your Appointment in Minutes",
description:
"Our easy-to-use online booking system lets you schedule appointments 24/7, choose your preferred dentist, and manage your dental health journey.",
buttonText: "Book Now",
buttonUrl: "patient/book-appointment",
},
companiesTitle = "Trusted Insurance Partners",
companies = defaultCompanies,
achievementsTitle = "Our Impact in Numbers",
achievementsDescription = "Providing quality dental care and making appointments easier for thousands of patients across the Philippines.",
achievements = defaultAchievements,
}: About3Props = {}) => {
return (
<section className="py-32">
<div className="container">
<div className="mb-14 grid gap-5 text-center md:grid-cols-2 md:text-left">
<h1 className="text-5xl font-semibold">{title}</h1>
<p className="text-muted-foreground">{description}</p>
</div>
<div className="grid gap-7 lg:grid-cols-3">
<Image
src={mainImage.src}
alt={mainImage.alt}
className="size-full max-h-[620px] rounded-xl object-cover lg:col-span-2"
width={600}
height={400}
priority
/>
<div className="flex flex-col gap-7 md:flex-row lg:flex-col">
<div className="bg-muted flex flex-col justify-between gap-6 rounded-xl p-7 md:w-1/2 lg:w-auto">
<Image
src={breakout.src}
alt={breakout.alt}
className="mr-auto h-12"
width={48}
height={48}
/>
<div>
<p className="mb-2 text-lg font-semibold">{breakout.title}</p>
<p className="text-muted-foreground">{breakout.description}</p>
</div>
<Button variant="outline" className="mr-auto" asChild>
<a href={breakout.buttonUrl} target="_blank">
{breakout.buttonText}
</a>
</Button>
</div>
<Image
src={secondaryImage.src}
alt={secondaryImage.alt}
className="grow basis-0 rounded-xl object-cover md:w-1/2 lg:min-h-0 lg:w-auto"
width={600}
height={400}
priority
/>
</div>
</div>
<div className="py-32">
<p className="text-center">{companiesTitle} </p>
<div className="mt-8 flex flex-wrap justify-center gap-8">
{companies.map((company, idx) => (
<div className="flex items-center gap-3" key={company.src + idx}>
<Image
src={company.src}
alt={company.alt}
className="h-6 w-auto md:h-8"
width={32}
height={32}
/>
</div>
))}
</div>
</div>
<div className="relative overflow-hidden rounded-xl bg-gradient-to-br from-blue-50 via-purple-50 to-pink-50 dark:from-blue-950/30 dark:via-purple-950/30 dark:to-pink-950/30 p-10 md:p-16 shadow-xl">
<div className="flex flex-col gap-4 text-center md:text-left relative z-10">
<h2 className="text-4xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">{achievementsTitle}</h2>
<p className="text-muted-foreground max-w-xl">
{achievementsDescription}
</p>
</div>
<div className="mt-10 flex flex-wrap justify-between gap-10 text-center relative z-10">
{achievements.map((item, idx) => {
const gradients = [
"from-blue-500 to-cyan-500",
"from-purple-500 to-pink-500",
"from-green-500 to-emerald-500",
"from-orange-500 to-red-500",
];
return (
<div className="flex flex-col gap-4 group" key={item.label + idx}>
<p className="font-semibold text-muted-foreground group-hover:text-foreground transition-colors">{item.label}</p>
<span className={`text-4xl font-bold md:text-5xl bg-gradient-to-r ${gradients[idx % gradients.length]} bg-clip-text text-transparent`}>
{item.value}
</span>
</div>
);
})}
</div>
<div className="pointer-events-none absolute -top-1 right-1 z-0 hidden h-full w-full bg-[linear-gradient(to_right,hsl(var(--primary))_1px,transparent_1px),linear-gradient(to_bottom,hsl(var(--primary))_1px,transparent_1px)] bg-[size:80px_80px] opacity-5 [mask-image:linear-gradient(to_bottom_right,#000,transparent,transparent)] md:block"></div>
</div>
</div>
</section>
);
};
export { About };

View File

@@ -0,0 +1,89 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
interface Contact2Props {
title?: string;
description?: string;
phone?: string;
email?: string;
web?: { label: string; url: string };
}
const Contact = ({
title = "Get In Touch",
description = "Have questions about our services or need help with booking? We're here to help! Reach out to us and we'll get back to you as soon as possible.",
phone = "(+63) 917-123-4567",
email = "info@dentalucare.com",
web = { label: "dentalucare.com", url: "https://dentalucare.com" },
}: Contact2Props) => {
return (
<section className="py-32">
<div className="container">
<div className="mx-auto flex max-w-7xl flex-col justify-between gap-10 lg:flex-row lg:gap-20">
<div className="mx-auto flex max-w-sm flex-col justify-between gap-10">
<div className="text-center lg:text-left">
<h1 className="mb-2 text-5xl font-semibold lg:mb-1 lg:text-6xl">
{title}
</h1>
<p className="text-muted-foreground">{description}</p>
</div>
<div className="mx-auto w-fit lg:mx-0">
<h3 className="mb-6 text-center text-2xl font-semibold lg:text-left">
Contact Details
</h3>
<ul className="ml-4 list-disc">
<li>
<span className="font-bold">Phone: </span>
{phone}
</li>
<li>
<span className="font-bold">Email: </span>
<a href={`mailto:${email}`} className="underline">
{email}
</a>
</li>
<li>
<span className="font-bold">Web: </span>
<a href={web.url} target="_blank" className="underline">
{web.label}
</a>
</li>
</ul>
</div>
</div>
<div className="mx-auto flex max-w-3xl flex-col gap-6 rounded-lg border p-10">
<div className="flex gap-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="firstname">First Name</Label>
<Input type="text" id="firstname" placeholder="First Name" />
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="lastname">Last Name</Label>
<Input type="text" id="lastname" placeholder="Last Name" />
</div>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="email">Email</Label>
<Input type="email" id="email" placeholder="Email" />
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="subject">Subject</Label>
<Input type="text" id="subject" placeholder="Subject" />
</div>
<div className="grid w-full gap-1.5">
<Label htmlFor="message">Message</Label>
<Textarea placeholder="Type your message here." id="message" />
</div>
<Button className="w-full">Send Message</Button>
</div>
</div>
</div>
</section>
);
};
export { Contact };

View File

@@ -0,0 +1,133 @@
"use client";
import {
Calendar,
Clock,
CreditCard,
Bell,
UserCheck,
Search,
} from "lucide-react";
import { ShimmeringText } from "@/components/ui/shimmering-text";
const Features = () => {
const services = [
{
icon: <Calendar className="h-6 w-6" />,
title: "Easy Online Booking",
description:
"Book your dental appointment online anytime, anywhere. Choose your preferred date, time slot, and specific dental service with real-time availability.",
items: [
"Real-time Availability",
"Choose Date & Time",
"Service Selection",
],
gradient: "from-indigo-500 to-blue-500",
bgColor: "bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-indigo-950/30 dark:to-blue-950/30",
iconBg: "bg-gradient-to-br from-indigo-500 to-blue-500",
},
{
icon: <UserCheck className="h-6 w-6" />,
title: "Secure Patient Portal",
description:
"Create your secure account with email verification. Manage your profile, medical history, and view all your appointments in one dashboard.",
items: ["Profile Management", "Medical History", "Appointment Overview"],
gradient: "from-teal-500 to-cyan-500",
bgColor: "bg-gradient-to-br from-teal-50 to-cyan-50 dark:from-teal-950/30 dark:to-cyan-950/30",
iconBg: "bg-gradient-to-br from-teal-500 to-cyan-500",
},
{
icon: <Bell className="h-6 w-6" />,
title: "Smart Reminders",
description:
"Never miss an appointment with automatic email and SMS reminders. Stay informed about upcoming visits and important updates.",
items: ["Email Notifications", "SMS Reminders", "Real-time Updates"],
gradient: "from-violet-500 to-purple-500",
bgColor: "bg-gradient-to-br from-violet-50 to-purple-50 dark:from-violet-950/30 dark:to-purple-950/30",
iconBg: "bg-gradient-to-br from-violet-500 to-purple-500",
},
{
icon: <CreditCard className="h-6 w-6" />,
title: "Flexible Payments",
description:
"Pay consultation and booking fees conveniently online via credit card, e-wallet, or bank transfer. Secure and hassle-free transactions.",
items: ["Multiple Payment Methods", "Secure Checkout", "Payment History"],
gradient: "from-emerald-500 to-green-500",
bgColor: "bg-gradient-to-br from-emerald-50 to-green-50 dark:from-emerald-950/30 dark:to-green-950/30",
iconBg: "bg-gradient-to-br from-emerald-500 to-green-500",
},
{
icon: <Clock className="h-6 w-6" />,
title: "Appointment Management",
description:
"Full control over your appointments. View, reschedule, or cancel upcoming visits easily through your patient dashboard.",
items: ["View Appointments", "Reschedule Anytime", "Easy Cancellation"],
gradient: "from-amber-500 to-orange-500",
bgColor: "bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/30",
iconBg: "bg-gradient-to-br from-amber-500 to-orange-500",
},
{
icon: <Search className="h-6 w-6" />,
title: "Find Your Dentist",
description:
"Search for dentists by specialty or service. View detailed profiles with qualifications, experience, and patient reviews to make informed decisions.",
items: ["Dentist Profiles", "Read Reviews", "Compare Specialists"],
gradient: "from-pink-500 to-rose-500",
bgColor: "bg-gradient-to-br from-pink-50 to-rose-50 dark:from-pink-950/30 dark:to-rose-950/30",
iconBg: "bg-gradient-to-br from-pink-500 to-rose-500",
},
];
return (
<section className="py-32">
<div className="container">
<div className="mx-auto max-w-6xl space-y-12">
<div className="space-y-4 text-center">
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
<ShimmeringText
text="Features"
className="bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 bg-clip-text text-transparent"
shimmeringColor="rgb(147 51 234)"
color="rgb(79 70 229)"
duration={2}
/>
</h2>
<p className="text-muted-foreground mx-auto max-w-2xl text-lg tracking-tight md:text-xl">
Everything you need to manage your dental health journey. Book
appointments, track your history, and connect with top dentists.
</p>
</div>
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
{services.map((service, index) => (
<div
key={index}
className={`${service.bgColor} space-y-6 rounded-xl border p-8 shadow-lg dark:shadow-xl dark:shadow-gray-900/50 transition-shadow duration-300 hover:shadow-2xl dark:hover:shadow-2xl dark:hover:shadow-gray-900/70`}
>
<div className="flex items-center gap-4">
<div className={`${service.iconBg} text-white rounded-full p-3 shadow-lg`}>
{service.icon}
</div>
<h3 className="text-xl font-bold">{service.title}</h3>
</div>
<p className="text-muted-foreground leading-relaxed">
{service.description}
</p>
<div className="space-y-2">
{service.items.map((item, itemIndex) => (
<div key={itemIndex} className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full bg-gradient-to-r ${service.gradient}`} />
<span className="text-sm font-medium">{item}</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
</section>
);
};
export { Features };

View File

@@ -0,0 +1,148 @@
import React from "react";
import { FaFacebook, FaInstagram } from "react-icons/fa";
import Image from "next/image";
interface Footer7Props {
logo?: {
url: string;
src: string;
alt: string;
title: string;
};
sections?: Array<{
title: string;
links: Array<{ name: string; href: string }>;
}>;
description?: string;
socialLinks?: Array<{
icon: React.ReactElement;
href: string;
label: string;
}>;
copyright?: string;
legalLinks?: Array<{
name: string;
href: string;
}>;
}
const defaultSections = [
{
title: "Services",
links: [
{ name: "Preventive Care", href: "/services/preventive-care" },
{ name: "Cosmetic Dentistry", href: "/services/cosmetic-dentistry" },
{ name: "Orthodontics", href: "/services/orthodontics" },
{ name: "Pediatric Dentistry", href: "/services/pediatric-dentistry" },
{ name: "Emergency Care", href: "/services/emergency-care" },
],
},
{
title: "Patient Resources",
links: [
{ name: "Book Appointment", href: "/book" },
{ name: "Patient Portal", href: "/dashboard" },
{ name: "Insurance Info", href: "/insurance" },
{ name: "Financing Options", href: "/financing" },
{ name: "New Patient Forms", href: "/forms" },
],
},
{
title: "About Us",
links: [
{ name: "Our Team", href: "/team" },
{ name: "Our Clinic", href: "/clinic" },
{ name: "Contact Us", href: "/contact" },
{ name: "Careers", href: "/careers" },
],
},
];
const defaultSocialLinks = [
{ icon: <FaInstagram className="size-5" />, href: "#", label: "Instagram" },
{ icon: <FaFacebook className="size-5" />, href: "#", label: "Facebook" },
];
const defaultLegalLinks = [
{ name: "Terms and Conditions", href: "/docs/terms-and-conditions" },
{ name: "Privacy Policy", href: "/docs/privacy-policy" },
];
const Footer = ({
logo = {
url: "/",
src: "/tooth.svg",
alt: "Dental U Care Logo",
title: "Dental U Care",
},
sections = defaultSections,
description = "Your trusted online dental appointment system. Book appointments, manage your dental health, and connect with expert dentists - all in one place.",
socialLinks = defaultSocialLinks,
copyright = "© 2025 Dental U Care. All rights reserved.",
legalLinks = defaultLegalLinks,
}: Footer7Props) => {
return (
<section className="py-32">
<div className="container">
<div className="flex w-full flex-col justify-between gap-10 lg:flex-row lg:items-start lg:text-left">
<div className="flex w-full flex-col justify-between gap-6 lg:items-start">
{/* Logo */}
<div className="flex items-center gap-2 lg:justify-start">
<a href={logo.url}>
<Image
src={logo.src}
alt={logo.alt}
title={logo.title}
className="h-8"
width={32}
height={32}
/>
</a>
<h2 className="text-xl font-semibold">{logo.title}</h2>
</div>
<p className="text-muted-foreground max-w-[70%] text-sm">
{description}
</p>
<ul className="text-muted-foreground flex items-center space-x-6">
{socialLinks.map((social, idx) => (
<li key={idx} className="hover:text-primary font-medium">
<a href={social.href} aria-label={social.label}>
{social.icon}
</a>
</li>
))}
</ul>
</div>
<div className="grid w-full gap-6 md:grid-cols-3 lg:gap-20">
{sections.map((section, sectionIdx) => (
<div key={sectionIdx}>
<h3 className="mb-4 font-bold">{section.title}</h3>
<ul className="text-muted-foreground space-y-3 text-sm">
{section.links.map((link, linkIdx) => (
<li
key={linkIdx}
className="hover:text-primary font-medium"
>
<a href={link.href}>{link.name}</a>
</li>
))}
</ul>
</div>
))}
</div>
</div>
<div className="text-muted-foreground mt-8 flex flex-col justify-between gap-4 border-t py-8 text-xs font-medium md:flex-row md:items-center md:text-left">
<p className="order-2 lg:order-1">{copyright}</p>
<ul className="order-1 flex flex-col gap-2 md:order-2 md:flex-row">
{legalLinks.map((link, idx) => (
<li key={idx} className="hover:text-primary font-medium">
<a href={link.href}>{link.name}</a>
</li>
))}
</ul>
</div>
</div>
</section>
);
};
export { Footer };

View File

@@ -0,0 +1,101 @@
import React from "react"
export default function GetStartedGuide() {
return (
<section className="max-w-3xl mx-auto py-10 px-4">
<h1 className="text-3xl font-bold mb-4 text-center">Dental U Care Get Started Guide</h1>
<p className="mb-6 text-center">Welcome to Dental U Care!<br />Were excited to help you on your journey toward better oral health. Heres how to get started as a new patient or team member.</p>
<div className="mb-8">
<h2 className="text-2xl font-semibold mb-2">For Patients</h2>
<ol className="list-decimal list-inside space-y-4">
<li>
<strong>Register or Book Online</strong>
<ul className="list-disc list-inside ml-5 mt-1">
<li>Visit our website/app and click on Book Appointment or Register as New Patient.</li>
<li>Fill in your details: Name, email, contact number, and medical history.</li>
<li>Choose your preferred date, time, and service (e.g., cleaning, checkup, whitening).</li>
</ul>
</li>
<li>
<strong>Confirmation & Reminders</strong>
<ul className="list-disc list-inside ml-5 mt-1">
<li>After booking, youll receive a confirmation via email or SMS with your appointment details.</li>
<li>You can reschedule or cancel anytime via the link in your confirmation message.</li>
<li>Well send you reminders 24 hours before your appointment.</li>
</ul>
</li>
<li>
<strong>Before Your Visit</strong>
<ul className="list-disc list-inside ml-5 mt-1">
<li>Bring a valid ID and your insurance card (if applicable).</li>
<li>Arrive 10 minutes early for check-in and medical history review.</li>
</ul>
</li>
<li>
<strong>On Your Appointment Day</strong>
<ul className="list-disc list-inside ml-5 mt-1">
<li>Our friendly staff will guide you through your visit.</li>
<li>Feel free to ask about treatment plans or payment options.</li>
</ul>
</li>
<li>
<strong>Aftercare and Follow-Up</strong>
<ul className="list-disc list-inside ml-5 mt-1">
<li>You may receive aftercare instructions by email or SMS.</li>
<li>Book your next visit directly from our website or app whenever youre ready.</li>
</ul>
</li>
</ol>
</div>
<div className="mb-8">
<h2 className="text-2xl font-semibold mb-2">For Staff</h2>
<ol className="list-decimal list-inside space-y-4">
<li>
<strong>Secure Login</strong>
<ul className="list-disc list-inside ml-5 mt-1">
<li>Use your assigned credentials to log in to the Dental U Care admin portal.</li>
<li>Update your profile and verify your contact information.</li>
</ul>
</li>
<li>
<strong>Dashboard Overview</strong>
<ul className="list-disc list-inside ml-5 mt-1">
<li>Access the appointments dashboard to see upcoming bookings.</li>
<li>Use the patient records section to review medical histories or notes.</li>
</ul>
</li>
<li>
<strong>Managing Appointments</strong>
<ul className="list-disc list-inside ml-5 mt-1">
<li>Confirm, reschedule, or cancel bookings as needed.</li>
<li>Send reminders and follow-up messages to patients with a single click.</li>
</ul>
</li>
<li>
<strong>Treatment Documentation</strong>
<ul className="list-disc list-inside ml-5 mt-1">
<li>Log treatments, prescribe medicines, and upload documents directly to each patient record.</li>
</ul>
</li>
<li>
<strong>Communication</strong>
<ul className="list-disc list-inside ml-5 mt-1">
<li>Use the messaging tools to answer patient queries or coordinate internally.</li>
</ul>
</li>
</ol>
</div>
<div className="border-t pt-6 mt-8">
<h2 className="text-xl font-semibold mb-2">Need Help?</h2>
<ul className="list-disc list-inside ml-5">
<li>Call our help desk at <span className="font-medium">6-1234-5678</span>, or email <span className="font-medium">support@dentalucare.com</span>.</li>
<li>Visit our FAQ on the website for common questions about appointments, insurance, or care tips.</li>
</ul>
</div>
</section>
)
}

View File

@@ -0,0 +1,97 @@
import { ArrowRight, Calendar} from "lucide-react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { ShimmeringText } from "@/components/ui/shimmering-text";
import TypingText from "@/components/ui/typing-text";
import Link from "next/link";
const Hero = () => {
return (
<section>
<div className="container mt-5 ">
<div className="bg-muted/25 grid items-center gap-8 lg:grid-cols-2 border rounded-lg shadow-lg">
<div className="flex flex-col items-center p-16 text-center lg:items-start lg:text-left">
<div className="mb-6 flex items-center gap-3">
<Image
src="/tooth.svg"
alt="Dental U Care Logo"
width={40}
height={40}
className="h-10 w-10"
/>
<ShimmeringText
text="Dental U-Care"
className="text-2xl font-bold"
shimmeringColor="rgb(147 51 234)"
color="rgb(37 99 235)"
duration={2}
/>
</div>
<h1 className="my-6 text-pretty text-4xl font-bold lg:text-6xl min-h-[120px] lg:min-h-[160px] flex items-start">
<TypingText
text={[
"Your Smile, Our Priority",
"Book Appointments Online",
"Smile with Confidence",
"Expert Dental Care for you"
]}
typingSpeed={80}
deletingSpeed={50}
pauseDuration={2000}
loop={true}
showCursor={true}
cursorClassName="bg-primary"
cursorCharacter="|"
className="inline-block"
/>
</h1>
<p className="text-muted-foreground mb-8 max-w-xl lg:text-xl">
Book your dental appointment online in minutes. Choose your
preferred date, time, and service. Real-time availability with
instant confirmation.
</p>
<div className="flex w-full flex-col justify-center gap-2 sm:flex-row lg:justify-start">
<Button size="lg">
<Calendar className="mr-2 h-4 w-4" />
Book Appointment Now
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<Button variant="outline" size="lg">
<Link href="/get-started" className="flex items-center">
Get Started
</Link>
</Button>
</div>
<div className="mt-8 grid grid-cols-3 gap-6 text-center lg:text-left">
<div>
<p className="text-2xl font-bold">500+</p>
<p className="text-muted-foreground text-xs">Happy Patients</p>
</div>
<div>
<p className="text-2xl font-bold">4</p>
<p className="text-muted-foreground text-xs">Expert Dentists</p>
</div>
<div>
<p className="text-2xl font-bold">98%</p>
<p className="text-muted-foreground text-xs">
Satisfaction Rate
</p>
</div>
</div>
</div>
<div className="relative h-full w-full overflow-hidden rounded-r-lg sm:rounded-lg md:rounded-lg">
<Image
src="/smile.jpg"
alt="Professional dental care at Dental U Care"
width={600}
height={500}
priority
className="h-full w-full object-cover dark:brightness-75"
/>
</div>
</div>
</div>
</section>
);
};
export { Hero };

View File

@@ -0,0 +1,31 @@
"use client";
import { Navbar } from "./navbar";
import { useEffect, useState } from "react";
export function NavbarWrapper() {
const [user, setUser] = useState(null);
const [isUserAdmin, setIsUserAdmin] = useState(false);
useEffect(() => {
async function fetchUser() {
try {
const res = await fetch("/api/auth/session");
if (res.ok) {
const session = await res.json();
setUser(session.user);
setIsUserAdmin(session.user?.role === "admin");
} else {
setUser(null);
setIsUserAdmin(false);
}
} catch (error) {
console.error("NavbarWrapper: Error fetching session", error);
setUser(null);
setIsUserAdmin(false);
}
}
fetchUser();
}, []);
return <Navbar user={user} isAdmin={isUserAdmin} />;
}

View File

@@ -0,0 +1,651 @@
"use client";
import { MenuIcon, SearchIcon, LogOut, User, Shield } from "lucide-react";
import Link from "next/link";
import { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import Image from "next/image";
import { authClient } from "@/lib/auth-session/auth-client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "@/components/ui/navigation-menu";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { ModeToggle } from "../ui/mode-toggle";
import { cn } from "@/lib/utils";
type User = {
id: string;
name: string;
email: string;
image?: string | null;
role?: string;
} | null;
type NavbarProps = {
user?: User;
isAdmin?: boolean;
};
const Navbar = ({ user, isAdmin: userIsAdmin }: NavbarProps) => {
const [isScrolled, setIsScrolled] = useState(false);
const router = useRouter();
useEffect(() => {
let ticking = false;
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
setIsScrolled(window.scrollY > 50);
ticking = false;
});
ticking = true;
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const [showRequiredDialog, setShowRequiredDialog] = useState(false);
const [searchValue, setSearchValue] = useState("");
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleSignOut = async () => {
setShowLogoutDialog(false);
try {
await authClient.signOut();
toast.success("Signed out successfully");
router.push("/sign-in");
router.refresh();
} catch {
toast.error("Failed to sign out");
}
};
// Handle search submit
const handleSearch = (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (searchValue.trim()) {
router.push(`/search?query=${encodeURIComponent(searchValue.trim())}`);
}
};
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const Services = [
{
title: "Preventive Care",
description:
"Cleanings, exams, and routine check-ups to keep smiles healthy",
href: "/services/preventive-care",
},
{
title: "Cosmetic Dentistry",
description: "Teeth whitening, veneers, and smile makeovers",
href: "/services/cosmetic-dentistry",
},
{
title: "Orthodontics",
description: "Braces and clear aligners for children and adults",
href: "/services/orthodontics",
},
{
title: "Pediatric Dentistry",
description: "Gentle, kid-friendly dental care for your little ones",
href: "/services/pediatric-dentistry",
},
{
title: "Emergency Care",
description:
"Same-day treatment for tooth pain, injuries, and urgent issues",
href: "/services/emergency-care",
},
{
title: "Patient Resources",
description: "New patient forms, insurance info, and financing options",
href: "/patient-resources",
},
];
// Filtered suggestions based on searchValue
const suggestions =
searchValue.trim().length > 0
? Services.filter(
(s) =>
s.title.toLowerCase().includes(searchValue.toLowerCase()) ||
s.description.toLowerCase().includes(searchValue.toLowerCase())
)
: [];
const aboutItems = [
{
title: "Our Story",
description: "Learn about Dental U Care's mission and values",
href: "/#about",
},
{
title: "Our Team",
description: "Meet our expert dental professionals",
href: "/#team",
},
{
title: "Features",
description: "Discover our online booking system features",
href: "/#features",
},
{
title: "Pricing",
description: "Transparent pricing for all dental services",
href: "/#pricing",
},
];
return (
<section className="sticky top-0 z-50 py-2">
<div
className={cn(
"container transition-all duration-300",
isScrolled && "px-6 lg:px-12"
)}
>
<nav
className={cn(
"flex items-center justify-between rounded-full px-6 py-6 transition-all duration-300 ",
isScrolled
? "border-2 border-accent dark:border-gray-900 bg-background/80 shadow-lg"
: "border-2 border-accent dark:border-gray-800 bg-background/80 shadow-lg"
)}
>
<Link href="/" className="flex items-center gap-2">
<Image
src="/tooth.svg"
alt="Dental U Care"
width={32}
height={32}
className="h-8 w-8"
/>
<span className="text-2xl font-semibold bg-gradient-to-r from-blue-600 to-blue-800 text-transparent bg-clip-text tracking-tighter">
Dental U Care
</span>
</Link>
<NavigationMenu className="hidden lg:block">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger className="bg-transparent hover:bg-transparent focus:bg-transparent data-[active]:bg-transparent data-[state=open]:bg-transparent">
About
</NavigationMenuTrigger>
<NavigationMenuContent>
<div className="grid w-[500px] grid-cols-2 p-3">
{aboutItems.map((item, index) => (
<NavigationMenuLink
href={item.href}
key={index}
className="rounded-md p-3 transition-colors"
>
<div key={item.title}>
<p className="text-foreground mb-1 font-semibold">
{item.title}
</p>
<p className="text-muted-foreground text-sm">
{item.description}
</p>
</div>
</NavigationMenuLink>
))}
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger className="bg-transparent hover:bg-transparent focus:bg-transparent data-[active]:bg-transparent data-[state=open]:bg-transparent">
Services
</NavigationMenuTrigger>
<NavigationMenuContent>
<div className="grid w-[600px] grid-cols-2 p-3">
{Services.map((service, index) => (
<NavigationMenuLink
href={service.href}
key={index}
className="rounded-md p-3 transition-colors"
>
<div key={service.title}>
<p className="text-foreground mb-1 font-semibold">
{service.title}
</p>
<p className="text-muted-foreground text-sm">
{service.description}
</p>
</div>
</NavigationMenuLink>
))}
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink
href="/#pricing"
className="group inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-all hover:text-accent-foreground hover:border-b-2 hover:border-primary focus:outline-none disabled:pointer-events-none disabled:opacity-50"
>
Pricing
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink
href="/#contact"
className="group inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-all hover:text-accent-foreground hover:border-b-2 hover:border-primary focus:outline-none disabled:pointer-events-none disabled:opacity-50"
>
Contact
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
<div className="hidden items-center gap-4 lg:flex">
<div className="relative">
<form onSubmit={handleSearch} className="contents">
<InputGroup
className={cn(
"w-64 transition-all duration-300 border border-gray-300 hover:border-primary hover:shadow-sm dark:border-gray-700 dark:hover:border-primary rounded-md",
isScrolled && "w-58"
)}
>
<InputGroupInput
ref={inputRef}
placeholder="Search services..."
className="border-0 focus-visible:ring-0"
value={searchValue}
onChange={(e) => {
setSearchValue(e.target.value);
setShowSuggestions(true);
}}
onFocus={() => setShowSuggestions(true)}
onBlur={() =>
setTimeout(() => setShowSuggestions(false), 100)
}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
aria-label="Search services"
autoComplete="off"
/>
<InputGroupAddon>
<SearchIcon className="h-4 w-4" />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupButton size="sm" type="submit">
Search
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
{showSuggestions && suggestions.length > 0 && (
<ul className="absolute left-0 z-50 mt-1 w-full bg-background border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-56 overflow-auto">
{suggestions.map((s) => (
<li
key={s.title}
className="px-4 py-2 cursor-pointer hover:bg-accent"
onMouseDown={() => {
setShowSuggestions(false);
setSearchValue("");
router.push(s.href);
}}
>
<span className="font-semibold">{s.title}</span>
<span className="block text-xs text-muted-foreground">
{s.description}
</span>
</li>
))}
</ul>
)}
</form>
</div>
<ModeToggle />
{user ? (
<>
<Button
className={cn(isScrolled ? "hidden" : "lg:inline-flex")}
asChild
>
<Link href="/patient/book-appointment">Book Now</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="rounded-full"
>
<Avatar className="h-8 w-8">
<AvatarImage
src={user.image || undefined}
alt={user.name}
/>
<AvatarFallback>
{getInitials(user.name)}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user.name}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{userIsAdmin && (
<>
<DropdownMenuItem asChild>
<Link href="/admin" className="cursor-pointer">
<Shield className="mr-2 h-4 w-4" />
<span>Dashboard</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{user?.role === "dentist" && (
<>
<DropdownMenuItem asChild>
<Link href="/dentist" className="cursor-pointer">
<User className="mr-2 h-4 w-4" />
<span>Dashboard</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{user?.role === "patient" && (
<>
<DropdownMenuItem asChild>
<Link href="/patient" className="cursor-pointer">
<User className="mr-2 h-4 w-4" />
<span>Dashboard</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => setShowLogoutDialog(true)}
className="cursor-pointer text-red-600"
>
<LogOut className="mr-2 h-4 w-4" />
<span>Sign Out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
<Button
variant="outline"
className={cn(isScrolled ? "hidden" : "lg:inline-flex")}
>
<Link href="/sign-in">Sign In</Link>
</Button>
<Button
className={cn(isScrolled ? "hidden" : "lg:inline-flex")}
onClick={() => setShowRequiredDialog(true)}
>
Book Now
</Button>
</>
)}
</div>
<Sheet>
<SheetTrigger asChild className="lg:hidden">
<Button variant="outline" size="icon">
<MenuIcon className="h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent side="top" className="max-h-screen overflow-auto">
<SheetHeader>
<SheetTitle>
<a href="#" className="flex items-center gap-2">
<Image
src="/tooth.svg"
alt="Dental U Care"
width={32}
height={32}
className="h-8 w-8"
/>
<span className="text-lg font-semibold tracking-tighter">
Dental U Care
</span>
</a>
</SheetTitle>
</SheetHeader>
<div className="flex flex-col p-4">
<Accordion type="single" collapsible className="mb-2 mt-4">
<AccordionItem value="about" className="border-none">
<AccordionTrigger className="text-base hover:no-underline">
About
</AccordionTrigger>
<AccordionContent>
<div className="grid gap-2">
{aboutItems.map((item, index) => (
<a
href={item.href}
key={index}
className="rounded-md p-3 transition-colors"
>
<div key={item.title}>
<p className="text-foreground mb-1 font-semibold">
{item.title}
</p>
<p className="text-muted-foreground text-sm">
{item.description}
</p>
</div>
</a>
))}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="solutions" className="border-none">
<AccordionTrigger className="text-base hover:no-underline">
Services
</AccordionTrigger>
<AccordionContent>
<div className="grid md:grid-cols-2">
{Services.map((service, index) => (
<a
href={service.href}
key={index}
className="rounded-md p-3 transition-colors"
>
<div key={service.title}>
<p className="text-foreground mb-1 font-semibold">
{service.title}
</p>
<p className="text-muted-foreground text-sm">
{service.description}
</p>
</div>
</a>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="flex flex-col gap-6">
<Link href="/#contact" className="font-medium">
Contact
</Link>
</div>
<div className="mt-6 flex flex-col gap-4">
{user ? (
<>
<div className="flex items-center gap-3 rounded-lg border p-4">
<Avatar className="h-10 w-10">
<AvatarImage
src={user.image || undefined}
alt={user.name}
/>
<AvatarFallback>
{getInitials(user.name)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<p className="text-sm font-medium leading-none">
{user.name}
</p>
<p className="text-xs leading-none text-muted-foreground mt-1">
{user.email}
</p>
</div>
</div>
<Button asChild>
<Link href="/patient/book-appointment">Book Now</Link>
</Button>
{userIsAdmin && (
<Button variant="outline" asChild>
<Link href="/admin">
<Shield className="mr-2 h-4 w-4" />
Admin Dashboard
</Link>
</Button>
)}
{user?.role === "dentist" && (
<Button variant="outline" asChild>
<Link href="/dentist">
<User className="mr-2 h-4 w-4" />
Dentist Dashboard
</Link>
</Button>
)}
{user?.role === "patient" && (
<Button variant="outline" asChild>
<Link href="/patient">
<User className="mr-2 h-4 w-4" />
Dashboard
</Link>
</Button>
)}
<Button
variant="destructive"
onClick={() => setShowLogoutDialog(true)}
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</Button>
</>
) : (
<>
<Button variant="outline">
<Link href="/sign-in">Sign in</Link>
</Button>
<Button onClick={() => setShowRequiredDialog(true)}>
Book Now
</Button>
</>
)}
</div>
</div>
</SheetContent>
</Sheet>
</nav>
</div>
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Log out</DialogTitle>
<DialogDescription>
Are you sure you want to log out?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="destructive" onClick={handleSignOut}>
Log out
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showRequiredDialog} onOpenChange={setShowRequiredDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Sign in required</DialogTitle>
<DialogDescription>
You need to sign in to book an appointment. Would you like to sign
in now?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button
onClick={() => {
setShowRequiredDialog(false);
router.push("/sign-in");
}}
>
Sign in
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
};
export { Navbar };

View File

@@ -0,0 +1,286 @@
"use client";
import { Check } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface Service {
name: string;
price: string;
description?: string;
}
interface ServiceCategory {
id: string;
title: string;
badge: string;
services: Service[];
}
import React, { useState } from "react";
const Pricing = () => {
const [showRequiredDialog, setShowRequiredDialog] = useState(false);
const serviceCategories: ServiceCategory[] = [
{
id: "basic",
title: "Basic Services",
badge: "Essential",
services: [
{
name: "Dental Consultation / Checkup",
price: "₱500 ₱1,500",
description: "Basic dental examination to check overall condition of teeth and gums",
},
{
name: "Oral Prophylaxis (Cleaning)",
price: "₱1,200 ₱3,000",
description: "Regular teeth cleaning, recommended every 6 months",
},
{
name: "Tooth X-Ray",
price: "₱700 ₱2,500+",
description: "Depends on type (periapical, panoramic, etc.)",
},
{
name: "Simple Tooth Extraction",
price: "₱1,500 ₱5,000",
description: "Basic tooth extraction procedure",
},
{
name: "Deep Cleaning / Scaling and Root Planing",
price: "₱3,000 ₱10,000+",
description: "For early signs of gum disease, deeper cleaning procedure",
},
],
},
{
id: "fillings",
title: "Dental Fillings",
badge: "Restorative",
services: [
{
name: "Amalgam Filling (Silver)",
price: "₱800 ₱2,500",
description: "Traditional silver-colored filling material",
},
{
name: "Composite Filling (Tooth-colored)",
price: "₱1,500 ₱4,500+",
description: "Natural-looking tooth-colored filling",
},
{
name: "Ceramic/Gold Filling",
price: "₱5,000 ₱15,000+",
description: "Premium filling materials for durability",
},
],
},
{
id: "advanced",
title: "Advanced Treatments",
badge: "Popular",
services: [
{
name: "Surgical/Impacted Tooth Extraction",
price: "₱10,000 ₱30,000+",
description: "Complex extraction for impacted teeth",
},
{
name: "Root Canal Treatment",
price: "₱5,000 ₱20,000+",
description: "Treatment for infected tooth pulp, cleaned and sealed",
},
{
name: "Dental Crowns (Basic - Metal or PFM)",
price: "₱8,000 ₱20,000+",
description: "Cap for damaged tooth, metal or porcelain-fused-to-metal",
},
{
name: "Dental Crowns (Premium - Zirconia, Emax)",
price: "₱30,000 ₱45,000+",
description: "High-quality aesthetic crowns",
},
{
name: "Teeth Whitening (Bleaching)",
price: "₱9,000 ₱30,000+",
description: "Laser or in-clinic whitening procedure",
},
],
},
{
id: "replacement",
title: "Tooth Replacement",
badge: "Restoration",
services: [
{
name: "Partial Denture",
price: "₱10,000 ₱30,000+",
description: "Removable denture for missing teeth",
},
{
name: "Full Denture",
price: "Contact for pricing",
description: "Complete denture set, depends on number of teeth",
},
{
name: "Dental Bridges",
price: "₱20,000 ₱60,000+",
description: "Replacement of missing teeth using adjacent teeth",
},
{
name: "Dental Implants",
price: "₱80,000 ₱150,000+",
description: "Permanent tooth replacement using titanium post + crown",
},
],
},
{
id: "cosmetic",
title: "Cosmetic & Orthodontics",
badge: "Premium",
services: [
{
name: "Dental Veneers",
price: "₱12,000 ₱35,000+ per tooth",
description: "For aesthetic purposes - straight, white, beautiful teeth",
},
{
name: "Traditional Metal Braces",
price: "₱35,000 ₱80,000+",
description: "Classic metal braces for teeth alignment",
},
{
name: "Ceramic / Clear Braces",
price: "₱100,000 ₱200,000+",
description: "Aesthetic clear or tooth-colored braces",
},
],
},
];
return (
<section className="py-32 ml-10">
<div className="container">
<div className="mx-auto flex max-w-7xl flex-col gap-8">
<div className="text-center space-y-4">
<h2 className="text-pretty text-4xl font-bold lg:text-6xl">
Dental Services & Pricing
</h2>
<p className="text-muted-foreground max-w-3xl mx-auto lg:text-xl">
Transparent pricing for all your dental needs. Quality dental care at competitive rates.
</p>
</div>
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-5 h-auto gap-2">
{serviceCategories.map((category) => (
<TabsTrigger
key={category.id}
value={category.id}
className="text-sm sm:text-base"
>
{category.title}
</TabsTrigger>
))}
</TabsList>
{serviceCategories.map((category) => (
<TabsContent
key={category.id}
value={category.id}
className="mt-8 animate-in fade-in-50 slide-in-from-bottom-4 duration-500"
>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-2xl lg:text-3xl">{category.title}</CardTitle>
<Badge className="uppercase">{category.badge}</Badge>
</div>
<CardDescription>
Professional dental services with transparent pricing
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{category.services.map((service, index) => (
<div
key={index}
className="flex flex-col sm:flex-row sm:items-center justify-between p-4 rounded-lg border hover:border-primary transition-all duration-300 hover:shadow-md gap-2 animate-in fade-in-50 slide-in-from-left-4"
style={{ animationDelay: `${index * 100}ms` }}
>
<div className="flex-1">
<div className="flex items-start gap-2">
<Check className="size-5 text-primary mt-1 shrink-0" />
<div>
<h3 className="font-semibold text-lg">{service.name}</h3>
{service.description && (
<p className="text-muted-foreground text-sm mt-1">
{service.description}
</p>
)}
</div>
</div>
</div>
<div className="text-right sm:text-left sm:ml-4">
<span className="text-xl font-bold text-primary whitespace-nowrap">
{service.price}
</span>
</div>
</div>
))}
</div>
<div className="mt-6 flex justify-center animate-in fade-in-50 zoom-in-95 duration-500 delay-300">
<Button
size="lg"
className="w-full sm:w-auto"
onClick={() => setShowRequiredDialog(true)}
>
<Link href="patient/book-appointment" className="flex items-center">
Book Appointment
</Link>
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
))}
</Tabs>
</div>
</div>
<Dialog open={showRequiredDialog} onOpenChange={setShowRequiredDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Required To Sign In & Sign Up</DialogTitle>
<DialogDescription>
Please sign in or sign up to access this content.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline"><Link href="/sign-in">Sign In</Link></Button>
</DialogClose>
<Button variant="default">
<Link href="/sign-up">Sign Up</Link>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
};
export { Pricing };

View File

@@ -0,0 +1,83 @@
import React from "react";
export default function PrivacyPolicy() {
return (
<section className="max-w-3xl mx-auto py-10 px-4 text-gray-800">
<h1 className="text-3xl font-bold mb-4 text-center">Privacy Policy</h1>
<h2 className="text-xl font-semibold mb-2 text-center">Dental U Care</h2>
<p className="mb-6 text-center">
Dental U Care values your privacy and is committed to protecting your personal information. This Privacy Policy explains how we collect, use, store, and protect your data when you visit our clinic, website, or contact us through any communication channel.
</p>
<ol className="list-decimal pl-6 space-y-4">
<li>
<strong>Information We Collect</strong><br />
We collect the following types of information to deliver quality dental care and manage our services effectively:<br />
<ul className="list-disc pl-6">
<li>Personal Information: Name, address, contact number, email, and date of birth.</li>
<li>Medical and Dental Information: Health history, dental records, X-rays, treatment notes, and insurance details.</li>
<li>Payment Information: Billing addresses, payment records, and insurance claim details.</li>
<li>Digital Information: Information from your visits to our website, including cookies, browser type, and access time (if applicable).</li>
</ul>
</li>
<li>
<strong>How We Use Your Information</strong><br />
Your information is used solely for legitimate purposes related to your treatment and clinic operations:<br />
<ul className="list-disc pl-6">
<li>To provide dental diagnosis, care, and follow-up services.</li>
<li>To maintain accurate internal records and manage appointments.</li>
<li>To process billing, payments, and insurance claims.</li>
<li>To communicate important updates, reminders, and follow-up instructions.</li>
<li>To improve the quality and safety of our services.</li>
<li>To comply with local laws, regulations, or legal obligations.</li>
</ul>
</li>
<li>
<strong>Data Protection and Security</strong><br />
We implement security measures to protect your personal and health information from unauthorized access, misuse, or disclosure.<br />
Patient records are stored securely in electronic or physical form and accessed only by authorized personnel.<br />
Access to data is strictly limited to staff members who require it for care delivery or administrative purposes.
</li>
<li>
<strong>Information Sharing</strong><br />
Dental U Care respects the confidentiality of your information. We do not sell or rent patient data. Information may be shared only:<br />
<ul className="list-disc pl-6">
<li>With authorized healthcare professionals involved in your care.</li>
<li>With insurance providers for claim processing.</li>
<li>With laboratories or specialists when necessary for your treatment.</li>
<li>As required by law, such as health or regulatory reporting.</li>
</ul>
</li>
<li>
<strong>Retention of Records</strong><br />
Patient records are retained for as long as required by Philippine law or professional standards. Once retention periods expire, records are securely disposed of.
</li>
<li>
<strong>Your Rights</strong><br />
You have the right to:
<ul className="list-disc pl-6">
<li>Access and review your dental records.</li>
<li>Request correction of inaccurate or incomplete data.</li>
<li>Withdraw consent for certain uses, subject to legal and contractual limitations.</li>
<li>File a privacy-related complaint with the clinic management.</li>
</ul>
</li>
<li>
<strong>Cookies and Website Data (if applicable)</strong><br />
If you use our website, cookies may be used to enhance your browsing experience. You can disable cookies through your browser settings, but some features may not function properly.
</li>
<li>
<strong>Policy Updates</strong><br />
Dental U Care may update this Privacy Policy from time to time to comply with updated legal requirements or improve data management. Revisions will take effect immediately upon posting.
</li>
<li>
<strong>Contact Information</strong><br />
For questions, requests, or concerns regarding this Privacy Policy, please contact:<br />
Dental U Care<br />
Address: <span className="italic">[Baltan Street, Puerto Princesa City, Palawan]</span><br />
Phone: <span className="italic">[63+ 1234 5678]</span><br />
Email: <span className="italic">[info@dentalucare.com]</span>
</li>
</ol>
</section>
);
}

View File

@@ -0,0 +1,132 @@
"use client";
import {
Stethoscope,
Sparkles,
Brackets,
Drill,
Baby,
ShieldAlert,
} from "lucide-react";
import { ShimmeringText } from "@/components/ui/shimmering-text";
const Services = () => {
const services = [
{
icon: <Stethoscope className="h-6 w-6" />,
title: "General Dentistry",
description:
"Comprehensive oral health care including routine checkups, professional cleanings, and preventive treatments to maintain your dental health.",
items: [
"Routine Checkups",
"Professional Cleaning",
"Cavity Fillings",
],
gradient: "from-blue-500 to-cyan-500",
bgColor: "bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-950/30 dark:to-cyan-950/30",
iconBg: "bg-gradient-to-br from-blue-500 to-cyan-500",
},
{
icon: <Sparkles className="h-6 w-6" />,
title: "Cosmetic Dentistry",
description:
"Transform your smile with our advanced cosmetic procedures. From teeth whitening to complete smile makeovers, we help you achieve the perfect smile.",
items: ["Teeth Whitening", "Veneers", "Smile Makeover"],
gradient: "from-purple-500 to-pink-500",
bgColor: "bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/30 dark:to-pink-950/30",
iconBg: "bg-gradient-to-br from-purple-500 to-pink-500",
},
{
icon: <Brackets className="h-6 w-6" />,
title: "Orthodontics",
description:
"Straighten your teeth and correct bite issues with traditional braces or modern clear aligners for both children and adults.",
items: ["Traditional Braces", "Clear Aligners", "Retainers"],
gradient: "from-green-500 to-emerald-500",
bgColor: "bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/30 dark:to-emerald-950/30",
iconBg: "bg-gradient-to-br from-green-500 to-emerald-500",
},
{
icon: <Drill className="h-6 w-6" />,
title: "Restorative Care",
description:
"Restore damaged or missing teeth with dental implants, crowns, bridges, and root canal therapy using advanced techniques.",
items: ["Dental Implants", "Crowns & Bridges", "Root Canal Therapy"],
gradient: "from-orange-500 to-red-500",
bgColor: "bg-gradient-to-br from-orange-50 to-red-50 dark:from-orange-950/30 dark:to-red-950/30",
iconBg: "bg-gradient-to-br from-orange-500 to-red-500",
},
{
icon: <Baby className="h-6 w-6" />,
title: "Pediatric Dentistry",
description:
"Gentle, kid-friendly dental care designed to make children feel comfortable and establish healthy oral hygiene habits from an early age.",
items: ["Children's Checkups", "Fluoride Treatment", "Sealants"],
gradient: "from-yellow-500 to-amber-500",
bgColor: "bg-gradient-to-br from-yellow-50 to-amber-50 dark:from-yellow-950/30 dark:to-amber-950/30",
iconBg: "bg-gradient-to-br from-yellow-500 to-amber-500",
},
{
icon: <ShieldAlert className="h-6 w-6" />,
title: "Emergency Dental Care",
description:
"Same-day emergency services for dental injuries, severe toothaches, broken teeth, and other urgent dental issues.",
items: ["Tooth Pain Relief", "Broken Tooth Repair", "Same-Day Treatment"],
gradient: "from-rose-500 to-red-600",
bgColor: "bg-gradient-to-br from-rose-50 to-red-50 dark:from-rose-950/30 dark:to-red-950/30",
iconBg: "bg-gradient-to-br from-rose-500 to-red-600",
},
];
return (
<section className="py-32">
<div className="container">
<div className="mx-auto max-w-6xl space-y-12">
<div className="space-y-4 text-center">
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
<ShimmeringText
text="Our Dental Services"
className="bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent"
shimmeringColor="rgb(236 72 153)"
color="rgb(37 99 235)"
duration={2}
/>
</h2>
<p className="text-muted-foreground mx-auto max-w-2xl text-lg tracking-tight md:text-xl">
Comprehensive dental care tailored to your needs. From preventive treatments to advanced procedures, we provide exceptional care for the whole family.
</p>
</div>
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
{services.map((service, index) => (
<div
key={index}
className={`${service.bgColor} space-y-6 rounded-xl border p-8 shadow-lg dark:shadow-xl dark:shadow-gray-900/50 transition-shadow duration-300 hover:shadow-2xl dark:hover:shadow-2xl dark:hover:shadow-gray-900/70`}
>
<div className="flex items-center gap-4">
<div className={`${service.iconBg} text-white rounded-full p-3 shadow-lg`}>
{service.icon}
</div>
<h3 className="text-xl font-bold">{service.title}</h3>
</div>
<p className="text-muted-foreground leading-relaxed">
{service.description}
</p>
<div className="space-y-2">
{service.items.map((item, itemIndex) => (
<div key={itemIndex} className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full bg-gradient-to-r ${service.gradient}`} />
<span className="text-sm font-medium">{item}</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
</section>
);
};
export { Services };

129
components/landing/team.tsx Normal file
View File

@@ -0,0 +1,129 @@
import { Github, Linkedin, Twitter } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
interface TeamMember {
id: string;
name: string;
role: string;
avatar: string;
github?: string;
twitter?: string;
linkedin?: string;
}
interface Team1Props {
heading?: string;
subheading?: string;
description?: string;
members?: TeamMember[];
}
const Team = ({
heading = "Meet Our Expert Dental Team",
description = "Our team of highly qualified dentists and specialists are dedicated to providing you with exceptional dental care. Each brings years of experience and a passion for creating healthy, beautiful smiles.",
members = [
{
id: "member-1",
name: "Kath Estrada",
role: "Chief Dentist & Orthodontist",
avatar:
"/kath.jpg",
},
{
id: "member-2",
name: "Clyrelle Jade Cervantes",
role: "Cosmetic Dentistry Specialist",
avatar:
" /cervs.jpg",
},
{
id: "member-3",
name: "Von Vryan Arguelles",
role: "Oral Surgeon",
avatar:
"/von.jpg",
},
{
id: "member-4",
name: "Dexter Cabanag",
role: "Periodontist",
avatar:
"/dexter.jpg",
},
],
}: Team1Props) => {
return (
<section className="py-24 lg:py-32">
<div className="container mx-auto px-4">
<div className="mb-16 text-center">
<h2 className="mb-6 text-3xl font-bold tracking-tight lg:text-5xl">
{heading}
</h2>
<p className="text-muted-foreground mx-auto max-w-2xl text-lg leading-relaxed">
{description}
</p>
</div>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3 [&>*:last-child:nth-child(3n-2)]:col-start-2">
{members.map((member) => (
<div
key={member.id}
className="border rounded-lg p-6 hover:shadow-lg transition-shadow"
>
<div className="flex flex-col items-center text-center">
<div className="mb-4">
<Avatar className="size-20 lg:size-24">
<AvatarImage src={member.avatar} />
<AvatarFallback className="text-lg font-semibold">
{member.name}
</AvatarFallback>
</Avatar>
</div>
<div className="mb-6">
<h3 className="mb-1 text-lg font-semibold">{member.name}</h3>
<p className="text-primary text-sm font-medium">
{member.role}
</p>
</div>
<div className="flex gap-3">
{member.github && (
<a
href={member.github}
className="bg-muted/50 rounded-lg p-2"
>
<Github className="text-muted-foreground size-4" />
</a>
)}
{member.twitter && (
<a
href={member.twitter}
className="bg-muted/50 rounded-lg p-2"
>
<Twitter className="text-muted-foreground size-4" />
</a>
)}
{member.linkedin && (
<a
href={member.linkedin}
className="bg-muted/50 rounded-lg p-2"
>
<Linkedin className="text-muted-foreground size-4" />
</a>
)}
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
};
export { Team };

View File

@@ -0,0 +1,78 @@
import React from "react";
export default function TermsAndConditions() {
return (
<section className="max-w-3xl mx-auto py-10 px-4 text-gray-800">
<h1 className="text-3xl font-bold mb-4 text-center">Terms and Conditions</h1>
<h2 className="text-xl font-semibold mb-2 text-center">Dental U Care</h2>
<p className="mb-6 text-center">
Welcome to Dental U Care. By accessing our website, booking an appointment, or receiving services at our clinic, you agree to the following Terms and Conditions. Please read them carefully before engaging with our services.
</p>
<ol className="list-decimal pl-6 space-y-4">
<li>
<strong>General Terms</strong><br />
Dental U Care provides dental healthcare services in accordance with professional and ethical standards.<br />
These Terms apply to all patients, clients, and users who interact with our clinic, whether in person, by phone, or online.<br />
We reserve the right to update these Terms at any time. Any changes will be posted on our premises or website.
</li>
<li>
<strong>Appointments and Cancellations</strong><br />
Appointments can be booked via phone, online, or in person.<br />
Please arrive at least 10 minutes before your scheduled time.<br />
Appointment cancellations must be made at least 24 hours in advance. Late cancellations or missed appointments may result in a cancellation fee.
</li>
<li>
<strong>Payments and Billing</strong><br />
Fees for dental services are determined based on the procedure and disclosed prior to treatment whenever possible.<br />
Payment is required at the time of service unless otherwise arranged.<br />
We accept cash, major credit/debit cards, and approved insurance.<br />
Any additional laboratory or specialist fees will be discussed before proceeding.
</li>
<li>
<strong>Insurance and Claims</strong><br />
Dental U Care assists patients in processing insurance claims but is not responsible for approval or denial of coverage.<br />
Patients remain fully responsible for any amount not covered by insurance.
</li>
<li>
<strong>Patient Responsibilities</strong><br />
Patients must provide accurate and complete medical and dental history information.<br />
Any changes to health status, medications, or allergies must be reported promptly.<br />
Patients are expected to follow the dentists recommended care and post-treatment instructions.
</li>
<li>
<strong>Treatment Consent</strong><br />
Before any procedure, your dentist will explain the diagnosis, treatment options, and estimated costs.<br />
By agreeing to proceed, you acknowledge that you understand the risks and benefits of the treatment.<br />
Written consent may be required for certain procedures.
</li>
<li>
<strong>Privacy and Confidentiality</strong><br />
Dental U Care complies with applicable privacy laws regarding the protection of personal and medical information.<br />
Your records will not be shared without your consent, except as required by law or for insurance processing.
</li>
<li>
<strong>Disclaimer of Liability</strong><br />
While our professionals strive to provide high-quality care, results may vary depending on individual conditions.<br />
Dental U Care is not liable for post-treatment complications that arise from failure to follow prescribed care or external factors beyond our control.
</li>
<li>
<strong>Intellectual Property</strong><br />
All content on our website and marketing materialsincluding text, logos, and imagesis owned by Dental U Care and may not be copied or reproduced without permission.
</li>
<li>
<strong>Governing Law</strong><br />
These Terms are governed by and construed in accordance with the laws of the Republic of the Philippines.<br />
Any disputes shall be handled within the proper courts of Puerto Princesa City, Palawan.
</li>
<li>
<strong>Contact Information</strong><br />
For questions regarding these Terms, please contact:<br />
Dental U Care<br />
Address: <span className="italic">[Baltan Street, Puerto Princesa City, Palawan]</span><br />
Phone: <span className="italic">[63+ 1234 5678]</span><br />
Email: <span className="italic">[info@dentalucare.com]</span>
</li>
</ol>
</section>
);
}

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>
)
}

View File

@@ -0,0 +1,455 @@
"use client";
import { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Calendar, Clock, User, DollarSign, Eye } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
type Appointment = {
id: string;
date: Date;
timeSlot: string;
status: string;
notes: string | null;
dentist: {
name: string;
image: string | null;
};
service: {
name: string;
price: number | string;
};
payment: {
status: string;
amount: number;
} | null;
};
type AppointmentsListProps = {
appointments: Appointment[];
};
export function AppointmentsList({ appointments }: AppointmentsListProps) {
const [isLoading, setIsLoading] = useState<string | null>(null);
const [selectedAppointment, setSelectedAppointment] =
useState<Appointment | null>(null);
const upcomingAppointments = appointments.filter(
(apt) => new Date(apt.date) >= new Date() && apt.status !== "cancelled"
);
const pastAppointments = appointments.filter(
(apt) => new Date(apt.date) < new Date() || apt.status === "cancelled"
);
const handleCancelAppointment = async (appointmentId: string) => {
if (!confirm("Are you sure you want to cancel this appointment?")) {
return;
}
setIsLoading(appointmentId);
try {
const response = await fetch(`/api/appointments/${appointmentId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
status: "cancelled",
cancelReason: "Cancelled by patient",
}),
});
if (!response.ok) {
throw new Error("Failed to cancel appointment");
}
toast.success("Appointment cancelled successfully");
window.location.reload();
} catch (error) {
console.error(error);
toast.error("Failed to cancel appointment");
} finally {
setIsLoading(null);
}
};
const getStatusBadge = (status: string) => {
const variants: Record<
string,
"default" | "secondary" | "destructive" | "outline"
> = {
pending: "secondary",
confirmed: "default",
cancelled: "destructive",
completed: "outline",
rescheduled: "secondary",
};
return (
<Badge variant={variants[status] || "default"}>
{status.toUpperCase()}
</Badge>
);
};
const getPaymentBadge = (status: string) => {
const variants: Record<
string,
"default" | "secondary" | "destructive" | "outline"
> = {
paid: "default",
pending: "secondary",
failed: "destructive",
refunded: "outline",
};
return (
<Badge variant={variants[status] || "default"}>
{status.toUpperCase()}
</Badge>
);
};
const formatPrice = (price: number | string): string => {
if (typeof price === "string") {
return price;
}
if (isNaN(price)) {
return "Contact for pricing";
}
return `${price.toLocaleString()}`;
};
const renderAppointmentCard = (appointment: Appointment) => (
<Card key={appointment.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">
{appointment.service.name}
</CardTitle>
<CardDescription className="flex items-center gap-1 mt-1">
<User className="h-3 w-3" />
Dr. {appointment.dentist.name}
</CardDescription>
</div>
{getStatusBadge(appointment.status)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>{new Date(appointment.date).toLocaleDateString()}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span>{appointment.timeSlot}</span>
</div>
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-muted-foreground" />
<span>{formatPrice(appointment.service.price)}</span>
</div>
{appointment.payment && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Payment:</span>
{getPaymentBadge(appointment.payment.status)}
</div>
)}
</div>
{appointment.notes && (
<div className="text-sm">
<p className="font-medium">Notes:</p>
<p className="text-muted-foreground">{appointment.notes}</p>
</div>
)}
{appointment.status === "pending" ||
appointment.status === "confirmed" ? (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => setSelectedAppointment(appointment)}
>
<Eye className="h-4 w-4 mr-2" />
View Details
</Button>
<Button
variant="outline"
size="sm"
onClick={() => toast.info("Reschedule feature coming soon")}
>
Reschedule
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleCancelAppointment(appointment.id)}
disabled={isLoading === appointment.id}
>
{isLoading === appointment.id ? "Cancelling..." : "Cancel"}
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setSelectedAppointment(appointment)}
>
<Eye className="h-4 w-4 mr-2" />
View Details
</Button>
)}
{appointment.status === "completed" &&
appointment.payment?.status === "pending" && (
<Button className="w-full" size="sm">
Pay Now
</Button>
)}
</CardContent>
</Card>
);
return (
<>
<Tabs defaultValue="upcoming" className="w-full">
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="upcoming">
Upcoming ({upcomingAppointments.length})
</TabsTrigger>
<TabsTrigger value="past">
Past ({pastAppointments.length})
</TabsTrigger>
</TabsList>
<TabsContent value="upcoming" className="space-y-4 mt-6">
{upcomingAppointments.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">
No upcoming appointments
</p>
<Button
className="mt-4"
onClick={() =>
(window.location.href = "/patient/book-appointment")
}
>
Book an Appointment
</Button>
</CardContent>
</Card>
) : (
upcomingAppointments.map(renderAppointmentCard)
)}
</TabsContent>
<TabsContent value="past" className="space-y-4 mt-6">
{pastAppointments.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No past appointments</p>
</CardContent>
</Card>
) : (
pastAppointments.map(renderAppointmentCard)
)}
</TabsContent>
</Tabs>
{/* Appointment Details Dialog */}
<Dialog
open={!!selectedAppointment}
onOpenChange={() => setSelectedAppointment(null)}
>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-start justify-between">
<div>
<DialogTitle className="text-2xl">
Appointment Details
</DialogTitle>
<DialogDescription>
Booking ID: {selectedAppointment?.id}
</DialogDescription>
</div>
{selectedAppointment &&
getStatusBadge(selectedAppointment.status)}
</div>
</DialogHeader>
{selectedAppointment && (
<div className="space-y-6">
{/* Service Information */}
<div className="space-y-3">
<h3 className="font-semibold text-lg border-b pb-2">
Service Information
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Service</p>
<p className="font-medium">
{selectedAppointment.service.name}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Price</p>
<p className="font-medium">
{formatPrice(selectedAppointment.service.price)}
</p>
</div>
</div>
</div>
{/* Appointment Details */}
<div className="space-y-3">
<h3 className="font-semibold text-lg border-b pb-2">
Appointment Schedule
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-start gap-2">
<Calendar className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Date</p>
<p className="font-medium">
{new Date(selectedAppointment.date).toLocaleDateString(
"en-US",
{
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}
)}
</p>
</div>
</div>
<div className="flex items-start gap-2">
<Clock className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Time</p>
<p className="font-medium">
{selectedAppointment.timeSlot}
</p>
</div>
</div>
</div>
</div>
{/* Dentist Information */}
<div className="space-y-3">
<h3 className="font-semibold text-lg border-b pb-2">
Dentist Information
</h3>
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">
Your Dentist
</p>
<p className="font-medium">
Dr. {selectedAppointment.dentist.name}
</p>
</div>
</div>
</div>
{/* Payment Information */}
{selectedAppointment.payment && (
<div className="space-y-3">
<h3 className="font-semibold text-lg border-b pb-2">
Payment Information
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">
Payment Status
</p>
<div className="mt-1">
{getPaymentBadge(selectedAppointment.payment.status)}
</div>
</div>
<div>
<p className="text-sm text-muted-foreground">Amount</p>
<p className="font-medium">
{selectedAppointment.payment.amount.toLocaleString()}
</p>
</div>
</div>
</div>
)}
{/* Notes */}
{selectedAppointment.notes && (
<div className="space-y-3">
<h3 className="font-semibold text-lg border-b pb-2">
Special Requests / Notes
</h3>
<p className="text-sm bg-muted p-3 rounded-lg">
{selectedAppointment.notes}
</p>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2 pt-4 border-t">
{selectedAppointment.status === "pending" ||
selectedAppointment.status === "confirmed" ? (
<>
<Button
variant="outline"
className="flex-1"
onClick={() => {
setSelectedAppointment(null);
toast.info("Reschedule feature coming soon");
}}
>
Reschedule
</Button>
<Button
variant="destructive"
className="flex-1"
onClick={() => {
const id = selectedAppointment.id;
setSelectedAppointment(null);
handleCancelAppointment(id);
}}
disabled={isLoading === selectedAppointment.id}
>
Cancel Appointment
</Button>
</>
) : selectedAppointment.status === "completed" &&
selectedAppointment.payment?.status === "pending" ? (
<Button className="w-full">Pay Now</Button>
) : null}
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,404 @@
"use client"
import * as React from "react"
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconDotsVertical,
IconLayoutColumns,
IconSearch,
} from "@tabler/icons-react"
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
type PatientAppointment = {
id: string
date: Date
timeSlot: string
status: string
notes: string | null
dentist: {
name: string
specialization: string | null
}
service: {
name: string
price: number
}
}
const getStatusBadge = (status: string) => {
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
pending: "secondary",
confirmed: "default",
cancelled: "destructive",
completed: "outline",
rescheduled: "secondary",
}
return (
<Badge variant={variants[status] || "default"} className="text-xs">
{status.toUpperCase()}
</Badge>
)
}
const columns: ColumnDef<PatientAppointment>[] = [
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
id: "serviceName",
accessorFn: (row) => row.service.name,
header: "Service",
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.service.name}</p>
<p className="text-xs text-muted-foreground">{row.original.service.price.toFixed(2)}</p>
</div>
),
enableHiding: false,
},
{
id: "dentistName",
accessorFn: (row) => row.dentist.name,
header: "Dentist",
cell: ({ row }) => (
<div>
<p className="font-medium">Dr. {row.original.dentist.name}</p>
{row.original.dentist.specialization && (
<p className="text-xs text-muted-foreground">{row.original.dentist.specialization}</p>
)}
</div>
),
},
{
accessorKey: "date",
header: "Date & Time",
cell: ({ row }) => (
<div>
<p>{new Date(row.original.date).toLocaleDateString()}</p>
<p className="text-xs text-muted-foreground">{row.original.timeSlot}</p>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => getStatusBadge(row.original.status),
},
{
accessorKey: "notes",
header: "Notes",
cell: ({ row }) => (
<div className="max-w-[200px] truncate">
{row.original.notes || "-"}
</div>
),
},
{
id: "actions",
cell: ({ row }) => {
const canModify = row.original.status === "pending" || row.original.status === "confirmed"
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem>View Details</DropdownMenuItem>
{canModify && (
<>
<DropdownMenuItem>Reschedule</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Cancel</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
type PatientAppointmentsTableProps = {
appointments: PatientAppointment[]
}
export function PatientAppointmentsTable({ appointments }: PatientAppointmentsTableProps) {
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [sorting, setSorting] = React.useState<SortingState>([])
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
})
const table = useReactTable({
data: appointments,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="relative w-full max-w-sm">
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
<Input
placeholder="Search appointments..."
value={(table.getColumn("serviceName")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("serviceName")?.setFilterValue(event.target.value)
}
className="pl-8"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" && column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader className="bg-muted">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No appointments found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More