commit 39077550ef13d53a911f074c1c66fc1baaea7e49 Author: Iliyan Angelov Date: Sun Nov 16 14:29:51 2025 +0200 Dental Care diff --git a/.cursor/rules/issue-project-rules.mdc b/.cursor/rules/issue-project-rules.mdc new file mode 100644 index 0000000..daeb43b --- /dev/null +++ b/.cursor/rules/issue-project-rules.mdc @@ -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. \ No newline at end of file diff --git a/.cursor/rules/like-a-software-eginner.mdc b/.cursor/rules/like-a-software-eginner.mdc new file mode 100644 index 0000000..0796a0f --- /dev/null +++ b/.cursor/rules/like-a-software-eginner.mdc @@ -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 user’s 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 todo’s, 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. \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b1bcc4a --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e08fec --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..3361682 --- /dev/null +++ b/.vscode/mcp.json @@ -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": [] +} diff --git a/app/(auth)/forgot-password/forgot-password-form.tsx b/app/(auth)/forgot-password/forgot-password-form.tsx new file mode 100644 index 0000000..0fa0b34 --- /dev/null +++ b/app/(auth)/forgot-password/forgot-password-form.tsx @@ -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 ( +
+ + + Forgot Password + + Enter your email address and we'll send you a link to reset + your password + + + +
+ + + Email + setEmail(e.target.value)} + disabled={isLoading} + required + /> + + + + + Remember your password?{" "} + + Sign in + + + + +
+
+
+
+ ); +} diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..8571a6d --- /dev/null +++ b/app/(auth)/forgot-password/page.tsx @@ -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 ( +
+
+ +
+ Dental U Care Logo +
+ Dental U Care + + +
+
+ ); +} diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..2fb6bd0 --- /dev/null +++ b/app/(auth)/layout.tsx @@ -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}; +} diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..e2cc65a --- /dev/null +++ b/app/(auth)/reset-password/page.tsx @@ -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 ( +
+
+ +
+ Dental U Care Logo +
+ Dental U Care + + Loading...
}> + + +
+ + ); +} diff --git a/app/(auth)/reset-password/reset-password-form.tsx b/app/(auth)/reset-password/reset-password-form.tsx new file mode 100644 index 0000000..54fcead --- /dev/null +++ b/app/(auth)/reset-password/reset-password-form.tsx @@ -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) { + e.preventDefault(); + setShowPassword((s) => !s); + } + + function toggleConfirm(e: React.MouseEvent) { + 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 ( +
+ + + Reset Password + Enter your new password + + +
+ + + Password +
+ setPassword(e.target.value)} + placeholder="Enter your new password" + disabled={isLoading} + minLength={8} + required + /> + +
+

+ Must be at least 8 characters +

+
+ + + Confirm Password + +
+ setConfirmPassword(e.target.value)} + placeholder="Confirm your new password" + disabled={isLoading} + minLength={8} + required + /> + +
+
+ + + +
+ Don't have an account?{" "} + + Sign up + +
+
+
+
+
+
+ By clicking continue, you agree to our{" "} + Terms of Service and{" "} + Privacy Policy. +
+
+ ); +} diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx new file mode 100644 index 0000000..fb4ff9a --- /dev/null +++ b/app/(auth)/sign-in/page.tsx @@ -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 ( +
+
+
+ +
+ Dental U Care +
+ Dental U Care + +
+
+
+ +
+
+
+
+ Dental clinic interior +
+
+ ) +} diff --git a/app/(auth)/sign-in/sign-in-form.tsx b/app/(auth)/sign-in/sign-in-form.tsx new file mode 100644 index 0000000..1aaee51 --- /dev/null +++ b/app/(auth)/sign-in/sign-in-form.tsx @@ -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) { + 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 + ) { + 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 ( +
+ + {showVerifyNotice && ( +
+
+ Your email is not verified. Please check your inbox for the + verification link. +
+ +
+ )} +
+

Login to your account

+

+ Enter your email below to login to your account +

+
+ + Email + setEmail(e.target.value)} + disabled={isLoading} + required + /> + + +
+ Password + + Forgot your password? + +
+
+ setPassword(e.target.value)} + disabled={isLoading} + required + /> + +
+
+ + + + Or continue with + + + + Don't have an account?{" "} + + Sign up + + + +
+
+ ); +} diff --git a/app/(auth)/sign-up/page.tsx b/app/(auth)/sign-up/page.tsx new file mode 100644 index 0000000..f9301c0 --- /dev/null +++ b/app/(auth)/sign-up/page.tsx @@ -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 ( +
+
+
+ +
+ Dental U Care +
+ Dental U Care + +
+
+
+ +
+
+
+
+ Doctor +
+
+ ); +} diff --git a/app/(auth)/sign-up/sign-up-form.tsx b/app/(auth)/sign-up/sign-up-form.tsx new file mode 100644 index 0000000..ebd7a06 --- /dev/null +++ b/app/(auth)/sign-up/sign-up-form.tsx @@ -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) { + e.preventDefault(); + setShowPassword((s) => !s); + } + + function toggleConfirm(e: React.MouseEvent) { + 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 + ) { + 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 ( +
+ {showVerifyNotice && ( +
+
+ Your email is not verified. Please check your inbox for the + verification link. +
+ +
+ )} + +
+

Create your account

+

+ Fill in the form below to create your account +

+
+ + + Full Name + + setName(e.target.value)} + disabled={isLoading} + required + /> + + + + Email + + setEmail(e.target.value)} + disabled={isLoading} + required + className="h-9" + /> + + We'll use this to contact you. + + + + + Password + +
+ setPassword(e.target.value)} + disabled={isLoading} + minLength={8} + required + className="h-9" + /> + +
+ + Must be at least 8 characters long. + +
+ + + Confirm Password + +
+ setConfirmPassword(e.target.value)} + disabled={isLoading} + minLength={8} + required + className="h-9" + /> + +
+ + Please confirm your password. + +
+ + + + Or continue with + + + + Already have an account? Sign in + + +
+
+ ); +} diff --git a/app/(main)/admin/action.ts b/app/(main)/admin/action.ts new file mode 100644 index 0000000..f717b74 --- /dev/null +++ b/app/(main)/admin/action.ts @@ -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); +} diff --git a/app/(main)/admin/appointment-management/page.tsx b/app/(main)/admin/appointment-management/page.tsx new file mode 100644 index 0000000..26d2221 --- /dev/null +++ b/app/(main)/admin/appointment-management/page.tsx @@ -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 ( + +
+
+

Appointment Management

+

+ Manage all appointments in the system +

+
+ + +
+
+ ); +} diff --git a/app/(main)/admin/dentist-management/page.tsx b/app/(main)/admin/dentist-management/page.tsx new file mode 100644 index 0000000..018bc44 --- /dev/null +++ b/app/(main)/admin/dentist-management/page.tsx @@ -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 ( + +
+
+

Dentist Management

+

+ Manage all dentists in the system +

+
+ + +
+
+ ); +} diff --git a/app/(main)/admin/page.tsx b/app/(main)/admin/page.tsx new file mode 100644 index 0000000..c879043 --- /dev/null +++ b/app/(main)/admin/page.tsx @@ -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, 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 ( + +
+ +
+ +
+
+ +
+
+
+ ); +} diff --git a/app/(main)/admin/patient-management/page.tsx b/app/(main)/admin/patient-management/page.tsx new file mode 100644 index 0000000..c95e49a --- /dev/null +++ b/app/(main)/admin/patient-management/page.tsx @@ -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 ( + +
+
+

Patient Management

+

+ Manage all patients in the system +

+
+ + +
+
+ ); +} diff --git a/app/(main)/admin/service-management/page.tsx b/app/(main)/admin/service-management/page.tsx new file mode 100644 index 0000000..4b460ac --- /dev/null +++ b/app/(main)/admin/service-management/page.tsx @@ -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 ( + +
+
+

Service Management

+

Manage all dental services

+
+ + +
+
+ ); +} diff --git a/app/(main)/admin/settings/page.tsx b/app/(main)/admin/settings/page.tsx new file mode 100644 index 0000000..128cf5b --- /dev/null +++ b/app/(main)/admin/settings/page.tsx @@ -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 ( + + + + ); +} diff --git a/app/(main)/admin/user-management/page.tsx b/app/(main)/admin/user-management/page.tsx new file mode 100644 index 0000000..7d7ff46 --- /dev/null +++ b/app/(main)/admin/user-management/page.tsx @@ -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 ( + +
+
+

User Management

+

+ Manage all users in the system +

+
+ + +
+
+ ); +} diff --git a/app/(main)/dentist/appointments/page.tsx b/app/(main)/dentist/appointments/page.tsx new file mode 100644 index 0000000..1619dbd --- /dev/null +++ b/app/(main)/dentist/appointments/page.tsx @@ -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 ( + +
+
+

Appointments

+

+ Manage your patient appointments +

+
+ + +
+
+ ); +} diff --git a/app/(main)/dentist/page.tsx b/app/(main)/dentist/page.tsx new file mode 100644 index 0000000..680403f --- /dev/null +++ b/app/(main)/dentist/page.tsx @@ -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 ( + +
+
+

Welcome, Dr. {user.name}!

+

+ Manage your schedule and patients +

+
+ + {/* Statistics Cards */} +
+ + + + Today's Appointments + + + + +
+ {todayAppointments.length} +
+

+ Scheduled for today +

+
+
+ + + + Upcoming + + + +
{upcomingAppointments}
+

+ Future appointments +

+
+
+ + + + + Total Patients + + + + +
{totalPatients.length}
+

Unique patients

+
+
+ + + + Completed + + + +
{completedAppointments}
+

All time

+
+
+
+ + {/* Quick Actions */} +
+ + + + + + + + + +
+ + {/* Today's Schedule */} + + + Today's Schedule + {today.toLocaleDateString()} + + + {todayAppointments.length === 0 ? ( +

+ No appointments scheduled for today +

+ ) : ( +
+ {todayAppointments.map((appointment) => ( +
+
+

{appointment.patient.name}

+

+ {appointment.service.name} +

+

+ {appointment.timeSlot} +

+
+
+ + {appointment.status === "pending" && ( + + )} +
+
+ ))} +
+ )} +
+
+ + {/* Pending Appointments */} + + + Pending Appointments + + Appointments awaiting confirmation + + + + {pendingAppointments.length === 0 ? ( +

+ No pending appointments +

+ ) : ( +
+ {pendingAppointments.map((appointment) => ( +
+
+

{appointment.patient.name}

+

+ {appointment.service.name} +

+

+ {new Date(appointment.date).toLocaleDateString()} at{" "} + {appointment.timeSlot} +

+
+
+ + +
+
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/app/(main)/dentist/patients/page.tsx b/app/(main)/dentist/patients/page.tsx new file mode 100644 index 0000000..06587b0 --- /dev/null +++ b/app/(main)/dentist/patients/page.tsx @@ -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 ( + +
+
+

Patient Records

+

+ View your patients' information and history +

+
+ + +
+
+ ); +} diff --git a/app/(main)/dentist/schedule/page.tsx b/app/(main)/dentist/schedule/page.tsx new file mode 100644 index 0000000..9a69afb --- /dev/null +++ b/app/(main)/dentist/schedule/page.tsx @@ -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 ( + +
+
+

Manage Schedule

+

+ Set your working hours and availability +

+
+ + + + Working Hours + Configure your weekly schedule + + +

+ Schedule management feature coming soon +

+
+
+
+
+ ); +} diff --git a/app/(main)/dentist/settings/page.tsx b/app/(main)/dentist/settings/page.tsx new file mode 100644 index 0000000..2f9b2fe --- /dev/null +++ b/app/(main)/dentist/settings/page.tsx @@ -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 ( + + + + ); +} diff --git a/app/(main)/patient/appointments/page.tsx b/app/(main)/patient/appointments/page.tsx new file mode 100644 index 0000000..10fb966 --- /dev/null +++ b/app/(main)/patient/appointments/page.tsx @@ -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 ( + +
+
+

My Appointments

+

+ View and manage your dental appointments +

+
+ + {showSuccess && ( + + + + Your appointment has been successfully booked! Check your email + for confirmation. + + + )} + + +
+
+ ); +} diff --git a/app/(main)/patient/book-appointment/page.tsx b/app/(main)/patient/book-appointment/page.tsx new file mode 100644 index 0000000..5638c64 --- /dev/null +++ b/app/(main)/patient/book-appointment/page.tsx @@ -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 ( + + {showCanceled && ( + + + + Payment was canceled. You can try booking again. + + + )} + + + ); +} diff --git a/app/(main)/patient/health-records/page.tsx b/app/(main)/patient/health-records/page.tsx new file mode 100644 index 0000000..4eb8bb1 --- /dev/null +++ b/app/(main)/patient/health-records/page.tsx @@ -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 ( + +
+
+

Health Records

+

+ Your medical history and treatment records +

+
+ + {/* Personal Information */} + + + + + Personal Information + + + +
+

+ Full Name +

+

{userDetails?.name}

+
+
+

Email

+

{userDetails?.email}

+
+
+

Phone

+

+ {userDetails?.phone || "Not provided"} +

+
+
+

+ Date of Birth +

+

+ {userDetails?.dateOfBirth + ? new Date(userDetails.dateOfBirth).toLocaleDateString() + : "Not provided"} +

+
+
+

+ Address +

+

+ {userDetails?.address || "Not provided"} +

+
+
+
+ + {/* Medical History */} + + + + + Medical History + + + Important medical information for your dentist + + + + {userDetails?.medicalHistory ? ( +

+ {userDetails.medicalHistory} +

+ ) : ( +

+ No medical history recorded +

+ )} +
+
+ + {/* Treatment History */} + + + + + Treatment History + + Completed dental procedures + + + {!userDetails?.appointmentsAsPatient || + userDetails.appointmentsAsPatient.length === 0 ? ( +

+ No treatment history +

+ ) : ( +
+ {userDetails.appointmentsAsPatient.map((appointment) => ( +
+
+
+

+ {appointment.service.name} +

+

+ Dr. {appointment.dentist.name} +

+

+ {new Date(appointment.date).toLocaleDateString()} at{" "} + {appointment.timeSlot} +

+ {appointment.notes && ( +

+ Notes:{" "} + {appointment.notes} +

+ )} +
+
+
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/app/(main)/patient/page.tsx b/app/(main)/patient/page.tsx new file mode 100644 index 0000000..2beaa53 --- /dev/null +++ b/app/(main)/patient/page.tsx @@ -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 ( + +
+
+

Welcome back, {user.name}!

+

+ Manage your appointments and health records +

+
+ + +
+
+ ); +} diff --git a/app/(main)/patient/payments/page.tsx b/app/(main)/patient/payments/page.tsx new file mode 100644 index 0000000..d13261c --- /dev/null +++ b/app/(main)/patient/payments/page.tsx @@ -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 ( + +
+
+

Payment History

+

+ View your payment transactions +

+
+ + +
+
+ ); +} diff --git a/app/(main)/patient/settings/page.tsx b/app/(main)/patient/settings/page.tsx new file mode 100644 index 0000000..2d8a103 --- /dev/null +++ b/app/(main)/patient/settings/page.tsx @@ -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 ( + + + + ); +} diff --git a/app/(main)/profile/page.tsx b/app/(main)/profile/page.tsx new file mode 100644 index 0000000..1edca42 --- /dev/null +++ b/app/(main)/profile/page.tsx @@ -0,0 +1,9 @@ +import {Metadata} from "next" + +export const metadata: Metadata = { + title: "Profile", +}; + +export default function Profile (){ + return +} \ No newline at end of file diff --git a/app/api/appointments/[id]/edit/route.ts b/app/api/appointments/[id]/edit/route.ts new file mode 100644 index 0000000..f59dc03 --- /dev/null +++ b/app/api/appointments/[id]/edit/route.ts @@ -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 } + ); + } +} diff --git a/app/api/appointments/[id]/route.ts b/app/api/appointments/[id]/route.ts new file mode 100644 index 0000000..1596bb2 --- /dev/null +++ b/app/api/appointments/[id]/route.ts @@ -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 } + ); + } +} diff --git a/app/api/appointments/book/route.ts b/app/api/appointments/book/route.ts new file mode 100644 index 0000000..a10bf3d --- /dev/null +++ b/app/api/appointments/book/route.ts @@ -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 } + ); + } +} diff --git a/app/api/appointments/route.ts b/app/api/appointments/route.ts new file mode 100644 index 0000000..c9cb937 --- /dev/null +++ b/app/api/appointments/route.ts @@ -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 } + ); + } +} diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..c4160ae --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -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); diff --git a/app/api/auth/resend-verification/route.ts b/app/api/auth/resend-verification/route.ts new file mode 100644 index 0000000..ae67fae --- /dev/null +++ b/app/api/auth/resend-verification/route.ts @@ -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 } + ); + } +} diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..00230d1 --- /dev/null +++ b/app/api/auth/session/route.ts @@ -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 } + ); + } +} diff --git a/app/api/debug-session/route.ts b/app/api/debug-session/route.ts new file mode 100644 index 0000000..d452a33 --- /dev/null +++ b/app/api/debug-session/route.ts @@ -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 } + ); + } +} diff --git a/app/api/send-appointment-reminder/route.tsx b/app/api/send-appointment-reminder/route.tsx new file mode 100644 index 0000000..187083e --- /dev/null +++ b/app/api/send-appointment-reminder/route.tsx @@ -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: ( + + ), + }); + + if (error) { + return Response.json({ error }, { status: 500 }); + } + return Response.json(data); + } catch (error) { + return Response.json({ error }, { status: 500 }); + } +} diff --git a/app/api/users/[id]/role/route.ts b/app/api/users/[id]/role/route.ts new file mode 100644 index 0000000..d4b1961 --- /dev/null +++ b/app/api/users/[id]/role/route.ts @@ -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 } + ); + } +} diff --git a/app/docs/privacy-policy/page.tsx b/app/docs/privacy-policy/page.tsx new file mode 100644 index 0000000..b5d1728 --- /dev/null +++ b/app/docs/privacy-policy/page.tsx @@ -0,0 +1,5 @@ +import PrivacyPolicy from "@/components/landing/privacy-policy"; + +export default function PrivacyPolicyPage() { + return ; +} diff --git a/app/docs/terms-and-conditions/page.tsx b/app/docs/terms-and-conditions/page.tsx new file mode 100644 index 0000000..d64cd14 --- /dev/null +++ b/app/docs/terms-and-conditions/page.tsx @@ -0,0 +1,5 @@ +import TermsAndConditions from "@/components/landing/terms-and-conditions"; + +export default function TermsAndConditionsPage() { + return ; +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..d69538c Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/forbidden/forbidden.tsx b/app/forbidden/forbidden.tsx new file mode 100644 index 0000000..7726560 --- /dev/null +++ b/app/forbidden/forbidden.tsx @@ -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 ( +
+
+
+

403

+

Access Denied

+

+ You don't have permission to access this page. +

+
+
+ + +
+
+
+ ); +} diff --git a/app/forbidden/unauthorized.tsx b/app/forbidden/unauthorized.tsx new file mode 100644 index 0000000..b86623f --- /dev/null +++ b/app/forbidden/unauthorized.tsx @@ -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 ( +
+
+
+

401 - Unauthorized

+

Please sign in to continue.

+
+
+ +
+
+
+ ); +} diff --git a/app/get-started/page.tsx b/app/get-started/page.tsx new file mode 100644 index 0000000..e12a27f --- /dev/null +++ b/app/get-started/page.tsx @@ -0,0 +1,5 @@ +import GetStartedGuide from "@/components/landing/get-started"; + +export default function GetStartedPage() { + return ; +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..cf91523 --- /dev/null +++ b/app/globals.css @@ -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; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..971ba73 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + {children} + + + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..2d1ad19 --- /dev/null +++ b/app/page.tsx @@ -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 ( +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/app/patient-resources/page.tsx b/app/patient-resources/page.tsx new file mode 100644 index 0000000..1978274 --- /dev/null +++ b/app/patient-resources/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { Navbar } from "@/components/landing/navbar"; + +export default function PatientResourcesPage() { + return ( + <> + +
+

Patient Resources

+

+ Find new patient forms, insurance information, and financing options to help you prepare for your visit and manage your dental care. +

+ {/* Add more resource links or components here as needed */} +
+ + ); +} diff --git a/app/services/cosmetic-dentistry/page.tsx b/app/services/cosmetic-dentistry/page.tsx new file mode 100644 index 0000000..fa36aba --- /dev/null +++ b/app/services/cosmetic-dentistry/page.tsx @@ -0,0 +1,11 @@ +import { CosmeticDentistry } from "@/components/services/CosmeticDentistry"; +import { NavbarWrapper } from "@/components/landing/navbar-wrapper"; + +export default function CosmeticDentistryPage() { + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/app/services/emergency-care/page.tsx b/app/services/emergency-care/page.tsx new file mode 100644 index 0000000..2f85dc0 --- /dev/null +++ b/app/services/emergency-care/page.tsx @@ -0,0 +1,11 @@ +import { EmergencyCare } from "@/components/services/EmergencyCare"; +import { NavbarWrapper } from "@/components/landing/navbar-wrapper"; + +export default function EmergencyCarePage() { + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/app/services/orthodontics/page.tsx b/app/services/orthodontics/page.tsx new file mode 100644 index 0000000..2bd21ef --- /dev/null +++ b/app/services/orthodontics/page.tsx @@ -0,0 +1,11 @@ +import { Orthodontics } from "@/components/services/Orthodontics"; +import { NavbarWrapper } from "@/components/landing/navbar-wrapper"; + +export default function OrthodonticsPage() { + return ( +
+ + +
+ ); +} diff --git a/app/services/pediatric-dentistry/page.tsx b/app/services/pediatric-dentistry/page.tsx new file mode 100644 index 0000000..ee82248 --- /dev/null +++ b/app/services/pediatric-dentistry/page.tsx @@ -0,0 +1,11 @@ +import { PediatricDentistry } from "@/components/services/PediatricDentistry"; +import { NavbarWrapper } from "@/components/landing/navbar-wrapper"; + +export default function PediatricDentistryPage() { + return ( +
+ + +
+ ); +} diff --git a/app/services/preventive-care/page.tsx b/app/services/preventive-care/page.tsx new file mode 100644 index 0000000..87e8145 --- /dev/null +++ b/app/services/preventive-care/page.tsx @@ -0,0 +1,11 @@ +import {PreventiveCare} from "@/components/services/PreventiveCare"; +import { NavbarWrapper } from "@/components/landing/navbar-wrapper"; + +export default function PreventiveCarePage() { + return ( +
+ + +
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..e71ef8d --- /dev/null +++ b/components.json @@ -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" + } +} diff --git a/components/admin/appointments-table.tsx b/components/admin/appointments-table.tsx new file mode 100644 index 0000000..8df717c --- /dev/null +++ b/components/admin/appointments-table.tsx @@ -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 ( + + {status.toUpperCase()} + + ); +}; + +const getPaymentBadge = (status: string) => { + const variants: Record< + string, + "default" | "secondary" | "destructive" | "outline" + > = { + paid: "default", + pending: "secondary", + failed: "destructive", + refunded: "outline", + }; + + return ( + + {status.toUpperCase()} + + ); +}; + +const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + id: "patientName", + accessorFn: (row) => row.patient.name, + header: "Patient", + cell: ({ row }) => ( +
+

{row.original.patient.name}

+

+ {row.original.patient.email} +

+
+ ), + enableHiding: false, + }, + { + id: "dentistName", + accessorFn: (row) => row.dentist.name, + header: "Dentist", + cell: ({ row }) => Dr. {row.original.dentist.name}, + }, + { + id: "serviceName", + accessorFn: (row) => row.service.name, + header: "Service", + cell: ({ row }) => row.original.service.name, + }, + { + accessorKey: "date", + header: "Date & Time", + cell: ({ row }) => ( +
+

{new Date(row.original.date).toLocaleDateString()}

+

{row.original.timeSlot}

+
+ ), + }, + { + 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: () =>
Amount
, + cell: ({ row }) => { + if (row.original.payment) { + const amount = row.original.payment.amount; + return
₱{amount.toFixed(2)}
; + } + // service.price is a string (e.g., "₱500 – ₱1,500" or "₱1,500") + const price = row.original.service.price; + return
{price}
; + }, + }, +]; + +type AdminAppointmentsTableProps = { + appointments: Appointment[]; +}; + +export function AdminAppointmentsTable({ + appointments, +}: AdminAppointmentsTableProps) { + const router = useRouter(); + const [rowSelection, setRowSelection] = React.useState({}); + const [columnVisibility, setColumnVisibility] = + React.useState({}); + const [columnFilters, setColumnFilters] = React.useState( + [] + ); + const [sorting, setSorting] = React.useState([]); + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }); + const [isLoading, setIsLoading] = React.useState(false); + const [selectedAppointment, setSelectedAppointment] = + React.useState(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 = { + id: "actions", + cell: ({ row }) => ( + + + + + + setSelectedAppointment(row.original)} + > + View Details + + toast.info("Edit feature coming soon")} + > + Edit + + toast.info("Reschedule feature coming soon")} + > + Reschedule + + + + handleSingleAction( + () => cancelAppointments([row.original.id]), + "cancel appointment" + ) + } + > + Cancel + + + handleSingleAction( + () => deleteAppointment(row.original.id), + "delete appointment" + ) + } + > + Delete + + + + ), + }; + + 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 ( +
+
+
+ + + table.getColumn("patientName")?.setFilterValue(event.target.value) + } + className="pl-8" + /> +
+ + + + + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + +
+ + {/* Bulk Actions Toolbar */} + {table.getFilteredSelectedRowModel().rows.length > 0 && ( +
+ + {table.getFilteredSelectedRowModel().rows.length} selected + +
+ + + + + + + + + + handleBulkAction( + completeAppointments, + "complete appointments" + ) + } + > + Mark as Completed + + toast.info("Export feature coming soon")} + > + Export Selected + + + + handleBulkAction(cancelAppointments, "cancel appointments") + } + > + Cancel Selected + + + handleBulkAction(deleteAppointments, "delete appointments") + } + > + Delete Selected + + + +
+
+ )} + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No appointments found. + + + )} + +
+
+ +
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+ + +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+ + {/* Appointment Details Dialog */} + setSelectedAppointment(null)} + > + + +
+
+ + Appointment Details + + + Booking ID: {selectedAppointment?.id} + +
+ {selectedAppointment && + getStatusBadge(selectedAppointment.status)} +
+
+ + {selectedAppointment && ( +
+ {/* Patient Information */} +
+

+ Patient Information +

+
+
+ +
+

+ Patient Name +

+

+ {selectedAppointment.patient.name} +

+
+
+
+ +
+

Email

+

+ {selectedAppointment.patient.email} +

+
+
+
+
+ + {/* Service Information */} +
+

+ Service Information +

+
+
+

Service

+

+ {selectedAppointment.service.name} +

+
+
+

Price

+

+ {formatPrice(selectedAppointment.service.price)} +

+
+
+
+ + {/* Appointment Schedule */} +
+

+ Appointment Schedule +

+
+
+ +
+

Date

+

+ {new Date(selectedAppointment.date).toLocaleDateString( + "en-US", + { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + } + )} +

+
+
+
+ +
+

Time

+

+ {selectedAppointment.timeSlot} +

+
+
+
+
+ + {/* Dentist Information */} +
+

+ Assigned Dentist +

+
+ +
+

Dentist

+

+ Dr. {selectedAppointment.dentist.name} +

+
+
+
+ + {/* Payment Information */} + {selectedAppointment.payment && ( +
+

+ Payment Information +

+
+
+

+ Payment Status +

+
+ {getPaymentBadge(selectedAppointment.payment.status)} +
+
+
+

Amount

+

+ ₱{selectedAppointment.payment.amount.toLocaleString()} +

+
+
+
+ )} + + {/* Notes */} + {selectedAppointment.notes && ( +
+

+ Special Requests / Notes +

+

+ {selectedAppointment.notes} +

+
+ )} + + {/* Admin Action Buttons */} +
+ {selectedAppointment.status === "pending" && ( + + )} + {(selectedAppointment.status === "pending" || + selectedAppointment.status === "confirmed") && ( + <> + + + + )} + {selectedAppointment.status === "confirmed" && ( + + )} +
+
+ )} +
+
+
+ ); +} diff --git a/components/admin/dentists-table.tsx b/components/admin/dentists-table.tsx new file mode 100644 index 0000000..2bafebf --- /dev/null +++ b/components/admin/dentists-table.tsx @@ -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[] = [ + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => ( +
+

Dr. {row.original.name}

+

{row.original.email}

+
+ ), + 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 ( +
+

{appointmentCount} total

+

+ {completedCount} completed +

+
+ ); + }, + }, + { + accessorKey: "isAvailable", + header: "Status", + cell: ({ row }) => ( + + {row.original.isAvailable ? "Available" : "Unavailable"} + + ), + }, + { + accessorKey: "createdAt", + header: "Joined", + cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(), + }, + { + id: "actions", + cell: () => ( + + + + + + View Details + Edit Profile + View Schedule + + Deactivate + + + ), + }, +]; + +type AdminDentistsTableProps = { + dentists: Dentist[]; +}; + +export function AdminDentistsTable({ dentists }: AdminDentistsTableProps) { + const [rowSelection, setRowSelection] = React.useState({}); + const [columnVisibility, setColumnVisibility] = + React.useState({}); + const [columnFilters, setColumnFilters] = React.useState( + [] + ); + const [sorting, setSorting] = React.useState([]); + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }); + const [isLoading, setIsLoading] = React.useState(false); + const [selectedDentist, setSelectedDentist] = React.useState( + 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 = { + id: "actions", + cell: ({ row }) => ( + + + + + + setSelectedDentist(row.original)}> + View Details + + toast.info("Edit feature coming soon")} + > + Edit Profile + + toast.info("Schedule feature coming soon")} + > + View Schedule + + + + handleSingleAction( + () => + updateDentistAvailability( + [row.original.id], + !row.original.isAvailable + ), + row.original.isAvailable ? "set unavailable" : "set available" + ) + } + > + {row.original.isAvailable ? "Set Unavailable" : "Set Available"} + + { + if (confirm("Are you sure you want to delete this dentist?")) { + handleSingleAction( + () => deleteDentist(row.original.id), + "delete dentist" + ); + } + }} + > + Delete + + + + ), + }; + + 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 ( +
+
+
+ + + table.getColumn("name")?.setFilterValue(event.target.value) + } + className="pl-8" + /> +
+ + + + + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + +
+ + {/* Bulk Actions Toolbar */} + {table.getFilteredSelectedRowModel().rows.length > 0 && ( +
+ + {table.getFilteredSelectedRowModel().rows.length} selected + +
+ + + + + + + + + toast.info("Send notification feature coming soon") + } + > + Send Notification + + toast.info("Export feature coming soon")} + > + Export Selected + + toast.info("Schedule feature coming soon")} + > + View Schedules + + + { + if ( + confirm( + "Are you sure you want to deactivate these dentists?" + ) + ) { + handleBulkAvailability(false); + } + }} + > + Deactivate Selected + + + +
+
+ )} + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No dentists found. + + + )} + +
+
+ +
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+ + +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+ + {/* Dentist Details Dialog */} + setSelectedDentist(null)} + > + + +
+
+ Dentist Details + ID: {selectedDentist?.id} +
+ {selectedDentist && ( + + {selectedDentist.isAvailable ? "Available" : "Unavailable"} + + )} +
+
+ + {selectedDentist && ( +
+ {/* Personal Information */} +
+

+ Personal Information +

+
+
+ +
+

Full Name

+

Dr. {selectedDentist.name}

+
+
+
+ +
+

Email

+

+ {selectedDentist.email} +

+
+
+ {selectedDentist.phone && ( +
+ +
+

Phone

+

{selectedDentist.phone}

+
+
+ )} +
+ +
+

Joined

+

+ {new Date(selectedDentist.createdAt).toLocaleDateString( + "en-US", + { + year: "numeric", + month: "long", + day: "numeric", + } + )} +

+
+
+
+
+ + {/* Professional Information */} +
+

+ Professional Information +

+
+ {selectedDentist.specialization && ( +
+ +
+

+ Specialization +

+

+ {selectedDentist.specialization} +

+
+
+ )} + {selectedDentist.experience && ( +
+ +
+

+ Experience +

+

+ {selectedDentist.experience} +

+
+
+ )} +
+ {selectedDentist.qualifications && ( +
+

+ Qualifications +

+

+ {selectedDentist.qualifications} +

+
+ )} +
+ + {/* Appointment Statistics */} +
+

+ Appointment Statistics +

+
+
+

+ {selectedDentist.appointmentsAsDentist.length} +

+

+ Total Appointments +

+
+
+

+ { + selectedDentist.appointmentsAsDentist.filter( + (apt) => apt.status === "completed" + ).length + } +

+

Completed

+
+
+

+ { + selectedDentist.appointmentsAsDentist.filter( + (apt) => + apt.status === "pending" || + apt.status === "confirmed" + ).length + } +

+

Upcoming

+
+
+
+ + {/* Action Buttons */} +
+ + + +
+
+ )} +
+
+
+ ); +} diff --git a/components/admin/patients-table.tsx b/components/admin/patients-table.tsx new file mode 100644 index 0000000..7813142 --- /dev/null +++ b/components/admin/patients-table.tsx @@ -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[] = [ + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + enableHiding: false, + }, + { + accessorKey: "contact", + header: "Contact", + cell: ({ row }) => ( +
+
+ + {row.original.email} +
+ {row.original.phone && ( +
+ + {row.original.phone} +
+ )} +
+ ), + }, + { + 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: () =>
Total Spent
, + cell: ({ row }) => { + const totalSpent = row.original.payments.reduce( + (sum, payment) => sum + payment.amount, + 0 + ); + return
₱{totalSpent.toFixed(2)}
; + }, + }, + { + accessorKey: "createdAt", + header: "Joined", + cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(), + }, + { + id: "actions", + cell: () => ( + + + + + + View Details + Edit Profile + View History + + Delete + + + ), + }, +]; + +type AdminPatientsTableProps = { + patients: Patient[]; +}; + +export function AdminPatientsTable({ patients }: AdminPatientsTableProps) { + const [rowSelection, setRowSelection] = React.useState({}); + const [columnVisibility, setColumnVisibility] = + React.useState({}); + const [columnFilters, setColumnFilters] = React.useState( + [] + ); + const [sorting, setSorting] = React.useState([]); + 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 ( +
+
+
+ + + table.getColumn("name")?.setFilterValue(event.target.value) + } + className="pl-8" + /> +
+ + + + + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + +
+ + {/* Bulk Actions Toolbar */} + {table.getFilteredSelectedRowModel().rows.length > 0 && ( +
+ + {table.getFilteredSelectedRowModel().rows.length} selected + +
+ + + + + + + + Export Selected + Add to Group + Send Appointment Reminder + + + Delete Selected + + + +
+
+ )} + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No patients found. + + + )} + +
+
+ +
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+ + +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
+ ); +} diff --git a/components/admin/services-table.tsx b/components/admin/services-table.tsx new file mode 100644 index 0000000..383b358 --- /dev/null +++ b/components/admin/services-table.tsx @@ -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[] = [ + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "name", + header: "Service Name", + cell: ({ row }) => ( +
+

{row.original.name}

+

+ {row.original.description} +

+
+ ), + enableHiding: false, + }, + { + accessorKey: "category", + header: "Category", + cell: ({ row }) => ( + + {row.original.category} + + ), + }, + { + accessorKey: "duration", + header: "Duration", + cell: ({ row }) => `${row.original.duration} mins`, + }, + { + accessorKey: "price", + header: () =>
Price
, + cell: ({ row }) => ( +
+ {row.original.price || "N/A"} +
+ ), + }, + { + accessorKey: "bookings", + header: "Bookings", + cell: ({ row }) => row.original.appointments.length, + }, + { + accessorKey: "isActive", + header: "Status", + cell: ({ row }) => ( + + {row.original.isActive ? "Active" : "Inactive"} + + ), + }, +]; + +type AdminServicesTableProps = { + services: Service[]; +}; + +export function AdminServicesTable({ services }: AdminServicesTableProps) { + const router = useRouter(); + const [rowSelection, setRowSelection] = React.useState({}); + const [columnVisibility, setColumnVisibility] = + React.useState({}); + const [columnFilters, setColumnFilters] = React.useState( + [] + ); + const [sorting, setSorting] = React.useState([]); + 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 = { + id: "actions", + cell: ({ row }) => ( + + + + + + toast.info("Edit feature coming soon")} + > + Edit Service + + + handleServiceAction( + () => duplicateService(row.original.id), + "duplicate service" + ) + } + > + Duplicate + + + + handleServiceAction( + () => + updateServiceStatus( + [row.original.id], + !row.original.isActive + ), + "toggle service status" + ) + } + > + {row.original.isActive ? "Deactivate" : "Activate"} + + + + handleServiceAction( + () => deleteService(row.original.id), + "delete service" + ) + } + > + Delete + + + + ), + }; + + 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 ( +
+
+
+ + + table.getColumn("name")?.setFilterValue(event.target.value) + } + className="pl-8" + /> +
+ + + + + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + +
+ + {/* Bulk Actions Toolbar */} + {table.getFilteredSelectedRowModel().rows.length > 0 && ( +
+ + {table.getFilteredSelectedRowModel().rows.length} selected + +
+ + + + + + + + toast.info("Duplicate feature coming soon")} + > + Duplicate Selected + + + toast.info("Update prices feature coming soon") + } + > + Update Prices + + toast.info("Export feature coming soon")} + > + Export Selected + + + + handleBulkAction(deleteServices, "delete services") + } + > + Delete Selected + + + +
+
+ )} + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No services found. + + + )} + +
+
+ +
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+ + +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
+ ); +} diff --git a/components/admin/settings-content.tsx b/components/admin/settings-content.tsx new file mode 100644 index 0000000..9661fde --- /dev/null +++ b/components/admin/settings-content.tsx @@ -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 ( +
+
+
+

Admin Settings

+

+ Manage your clinic settings and preferences +

+
+
+ + + + + + General + + + + Appointments + + + + Notifications + + + + Payments + + + + Security + + + + {/* General Settings */} + + + + Clinic Information + + Update your clinic's basic information and contact details + + + +
+
+ + setClinicName(e.target.value)} + placeholder="Dental U-Care" + /> +
+
+ + setClinicEmail(e.target.value)} + placeholder="info@dentalucare.com" + /> +
+
+ + setClinicPhone(e.target.value)} + placeholder="+1 (555) 123-4567" + /> +
+
+ + +
+
+
+ +