Dental Care
This commit is contained in:
6
.cursor/rules/issue-project-rules.mdc
Normal file
6
.cursor/rules/issue-project-rules.mdc
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
Be very detailed with summarization and do not miss out things that are important.
|
||||
|
||||
Important: try to fix things at the cause, not the symptom.
|
||||
37
.cursor/rules/like-a-software-eginner.mdc
Normal file
37
.cursor/rules/like-a-software-eginner.mdc
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
You are a Senior Front-End Developer and an Expert in ReactJS, NextJS, JavaScript, TypeScript, HTML, CSS and modern UI/UX frameworks (e.g., TailwindCSS, Shadcn, Radix). You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning.
|
||||
|
||||
- Follow the 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.
|
||||
35
.env.example
Normal file
35
.env.example
Normal file
@@ -0,0 +1,35 @@
|
||||
# App Configuration
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
# Better Auth
|
||||
BETTER_AUTH_SECRET=your_secret_key_here
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# Database - Prisma Accelerate (optimized for Prisma ORM)
|
||||
DATABASE_URL="prisma+postgres://accelerate.prisma-data.net/?api_key=your_api_key_here"
|
||||
|
||||
# Direct Database Connection (for migrations and Prisma Studio)
|
||||
DIRECT_URL="postgresql://user:password@host:5432/database?sslmode=require"
|
||||
|
||||
# Resend API Key (for email sending)
|
||||
# Get from: https://resend.com/api-keys
|
||||
RESEND_API_KEY=your_resend_api_key_here
|
||||
|
||||
# Google OAuth Configuration
|
||||
# Get these from: https://console.cloud.google.com/apis/dashboard
|
||||
GOOGLE_CLIENT_ID=your_google_client_id_here
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
|
||||
|
||||
# For local development, set redirect URI to:
|
||||
# http://localhost:3000/api/auth/callback/google
|
||||
|
||||
# For production, set redirect URI to:
|
||||
# https://yourdomain.com/api/auth/callback/google
|
||||
|
||||
# Stripe Configuration
|
||||
# Get these from: https://dashboard.stripe.com/apikeys
|
||||
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here
|
||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
|
||||
|
||||
# Stripe Publishable Key (for client-side usage)
|
||||
NEXT_STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key_here
|
||||
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# prisma
|
||||
/prisma/*.db
|
||||
/prisma/*.db-journal
|
||||
/prisma/migrations
|
||||
|
||||
27
.vscode/mcp.json
vendored
Normal file
27
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"servers": {
|
||||
"my-mcp-server-f475f1de": {
|
||||
"url": "https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp",
|
||||
"type": "http"
|
||||
},
|
||||
"my-mcp-server-5e3fe95b": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"shadcn@latest",
|
||||
"mcp",
|
||||
"init",
|
||||
"--client",
|
||||
"vscode"
|
||||
]
|
||||
},
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"shadcn@latest",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
104
app/(auth)/forgot-password/forgot-password-form.tsx
Normal file
104
app/(auth)/forgot-password/forgot-password-form.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-session/auth-client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function ForgotPasswordForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { error } = await authClient.forgetPassword({
|
||||
email,
|
||||
redirectTo: `${window.location.origin}/reset-password`,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error("Failed to send reset link", {
|
||||
description: error.message || "Please try again.",
|
||||
});
|
||||
} else {
|
||||
toast.success("Password reset link sent!", {
|
||||
description: "Please check your email inbox.",
|
||||
});
|
||||
setEmail(""); // Clear the form on success
|
||||
}
|
||||
} catch {
|
||||
toast.error("An unexpected error occurred", {
|
||||
description: "Please try again later.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Forgot Password</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email address and we'll send you a link to reset
|
||||
your password
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@gmail.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Sending..." : "Send Reset Link"}
|
||||
</Button>
|
||||
<FieldDescription className="text-center">
|
||||
Remember your password?{" "}
|
||||
<a
|
||||
href="/sign-in"
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
app/(auth)/forgot-password/page.tsx
Normal file
26
app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ForgotPasswordForm } from "@/app/(auth)/forgot-password/forgot-password-form";
|
||||
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<Link href="/" className="flex items-center gap-2 self-center font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md p-1">
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
src={"/tooth.svg"}
|
||||
alt="Dental U Care Logo"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<span>Dental U Care</span>
|
||||
</Link>
|
||||
<ForgotPasswordForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
app/(auth)/layout.tsx
Normal file
13
app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export default async function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// Removed redundant client-side redirect here.
|
||||
// The sign-in form performs role-based redirects after login
|
||||
// and middleware protects authenticated routes. Keeping the
|
||||
// layout minimal avoids redirect race conditions.
|
||||
return <>{children}</>;
|
||||
}
|
||||
32
app/(auth)/reset-password/page.tsx
Normal file
32
app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { ResetPasswordForm } from "./reset-password-form";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 self-center font-medium"
|
||||
>
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md p-1">
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
src={"/tooth.svg"}
|
||||
alt="Dental U Care Logo"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<span>Dental U Care</span>
|
||||
</Link>
|
||||
<Suspense fallback={<div className="text-center">Loading...</div>}>
|
||||
<ResetPasswordForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
app/(auth)/reset-password/reset-password-form.tsx
Normal file
254
app/(auth)/reset-password/reset-password-form.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { authClient } from "@/lib/auth-session/auth-client";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
export function ResetPasswordForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
function togglePassword(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
e.preventDefault();
|
||||
setShowPassword((s) => !s);
|
||||
}
|
||||
|
||||
function toggleConfirm(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
e.preventDefault();
|
||||
setShowConfirm((s) => !s);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
if (!token) {
|
||||
toast.error("Invalid or missing reset token");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
toast.error("Password must be at least 8 characters");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error("Passwords do not match");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await authClient.resetPassword({
|
||||
newPassword: password,
|
||||
token,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message || "Failed to reset password");
|
||||
} else {
|
||||
toast.success("Password reset successfully");
|
||||
router.push("/sign-in");
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Reset Password</CardTitle>
|
||||
<CardDescription>Enter your new password</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your new password"
|
||||
disabled={isLoading}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
aria-label={
|
||||
showPassword ? "Hide password" : "Show password"
|
||||
}
|
||||
onClick={togglePassword}
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 text-sm opacity-70 hover:opacity-100"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.97 9.97 0 012.175-5.875M6.343 6.343A9.97 9.97 0 0112 5c5.523 0 10 4.477 10 10 0 1.042-.161 2.045-.463 2.998M3 3l18 18"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Must be at least 8 characters
|
||||
</p>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="confirmPassword">
|
||||
Confirm Password
|
||||
</FieldLabel>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirm ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your new password"
|
||||
disabled={isLoading}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
aria-label={
|
||||
showConfirm
|
||||
? "Hide confirm password"
|
||||
: "Show confirm password"
|
||||
}
|
||||
onClick={toggleConfirm}
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 text-sm opacity-70 hover:opacity-100"
|
||||
>
|
||||
{showConfirm ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.97 9.97 0 012.175-5.875M6.343 6.343A9.97 9.97 0 0112 5c5.523 0 10 4.477 10 10 0 1.042-.161 2.045-.463 2.998M3 3l18 18"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Resetting...
|
||||
</>
|
||||
) : (
|
||||
"Reset Password"
|
||||
)}
|
||||
</Button>
|
||||
</Field>
|
||||
<div className="text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/sign-up" className="underline underline-offset-4">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||
By clicking continue, you agree to our{" "}
|
||||
<Link href="/docs/terms-of-service">Terms of Service</Link> and{" "}
|
||||
<Link href="/docs/privacy-policy">Privacy Policy</Link>.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
app/(auth)/sign-in/page.tsx
Normal file
38
app/(auth)/sign-in/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import Image from "next/image"
|
||||
import { Metadata } from "next"
|
||||
import { LoginForm } from "@/app/(auth)/sign-in/sign-in-form"
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign in",
|
||||
};
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col p-6 md:p-10">
|
||||
<div className="flex justify-center md:justify-start flex-shrink-0 mb-8">
|
||||
<Link href="/" className="flex items-center gap-2 font-semibold text-lg">
|
||||
<div className=" text-primary-foreground flex size-10 items-center justify-center rounded-lg p-2">
|
||||
<Image src="/tooth.svg" alt="Dental U Care" width={24} height={24} />
|
||||
</div>
|
||||
Dental U Care
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-xs">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted relative hidden lg:block">
|
||||
<Image
|
||||
src="/dentist.jpg"
|
||||
alt="Dental clinic interior"
|
||||
fill
|
||||
className="object-cover dark:brightness-80"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
335
app/(auth)/sign-in/sign-in-form.tsx
Normal file
335
app/(auth)/sign-in/sign-in-form.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-session/auth-client";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"form">) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
const [showVerifyNotice, setShowVerifyNotice] = useState(false);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
const [resendSuccess, setResendSuccess] = useState(false);
|
||||
|
||||
function togglePassword(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
e.preventDefault();
|
||||
setShowPassword((s) => !s);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await authClient.signIn.email(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
},
|
||||
{
|
||||
onSuccess: async (ctx) => {
|
||||
// User data is available in ctx.data
|
||||
const user = ctx.data?.user;
|
||||
const role = user?.role;
|
||||
|
||||
setShowVerifyNotice(false);
|
||||
|
||||
// Determine target based on role
|
||||
const target =
|
||||
role === "admin"
|
||||
? "/admin"
|
||||
: role === "dentist"
|
||||
? "/dentist"
|
||||
: role === "patient"
|
||||
? "/patient"
|
||||
: "/";
|
||||
|
||||
const description =
|
||||
role === "admin"
|
||||
? "Redirecting to admin panel..."
|
||||
: role === "dentist"
|
||||
? "Redirecting to dentist portal..."
|
||||
: role === "patient"
|
||||
? "Redirecting to patient portal..."
|
||||
: "Welcome back!";
|
||||
|
||||
toast.success("Login successful!", { description });
|
||||
|
||||
// Small delay to ensure cookie is set before redirect
|
||||
// This is critical for production where cookie propagation takes time
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Use window.location.href for full page reload
|
||||
window.location.href = target;
|
||||
},
|
||||
onError: (ctx) => {
|
||||
if (ctx.error.status === 403) {
|
||||
setShowVerifyNotice(true);
|
||||
toast.error("Please verify your email address", {
|
||||
description: "Check your inbox for the verification link.",
|
||||
});
|
||||
} else {
|
||||
setShowVerifyNotice(false);
|
||||
toast.error("Login failed", {
|
||||
description: ctx.error.message || "Invalid email or password.",
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
toast.error("An unexpected error occurred", {
|
||||
description: "Please try again later.",
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResendVerification(
|
||||
e: React.MouseEvent<HTMLButtonElement>
|
||||
) {
|
||||
e.preventDefault();
|
||||
setResendLoading(true);
|
||||
setResendSuccess(false);
|
||||
try {
|
||||
const res = await fetch("/api/auth/resend-verification", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setResendSuccess(true);
|
||||
toast.success("Verification email sent!", {
|
||||
description: "Check your inbox for the verification link.",
|
||||
});
|
||||
} else {
|
||||
toast.error("Failed to resend verification email.");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to resend verification email.");
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleSignIn() {
|
||||
try {
|
||||
setIsGoogleLoading(true);
|
||||
|
||||
// Google OAuth redirects to Google, then back to /api/auth/callback/google
|
||||
// Better Auth handles the callback and creates/updates the session
|
||||
// The onAfterSignUp hook in auth.ts ensures new users get the "patient" role
|
||||
// After callback, users are redirected to root "/"
|
||||
// The auth layout then redirects to role-specific dashboard
|
||||
await authClient.signIn.social({
|
||||
provider: "google",
|
||||
});
|
||||
|
||||
// This will cause a redirect, so code after this won't execute
|
||||
} catch (error) {
|
||||
console.error("Google sign-in failed:", error);
|
||||
toast.error("Google sign-in failed", {
|
||||
description: "Please try again.",
|
||||
});
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className={cn("flex flex-col gap-6", className)}
|
||||
{...props}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<FieldGroup>
|
||||
{showVerifyNotice && (
|
||||
<div className="bg-yellow-100 border border-yellow-300 text-yellow-800 rounded p-3 text-center mb-2">
|
||||
<div className="mb-2">
|
||||
Your email is not verified. Please check your inbox for the
|
||||
verification link.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="underline text-sm text-blue-700 disabled:opacity-60"
|
||||
onClick={handleResendVerification}
|
||||
disabled={resendLoading || resendSuccess}
|
||||
>
|
||||
{resendLoading
|
||||
? "Resending..."
|
||||
: resendSuccess
|
||||
? "Verification Sent!"
|
||||
: "Resend Verification Email"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<h1 className="text-2xl font-bold">Login to your account</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Enter your email below to login to your account
|
||||
</p>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="e.g m@gmail.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<div className="flex items-center">
|
||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
onClick={togglePassword}
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 text-sm opacity-70 hover:opacity-100"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.97 9.97 0 012.175-5.875M6.343 6.343A9.97 9.97 0 0112 5c5.523 0 10 4.477 10 10 0 1.042-.161 2.045-.463 2.998M3 3l18 18"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit" disabled={isLoading || isGoogleLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
{"Logging in..."}
|
||||
</>
|
||||
) : (
|
||||
"Login"
|
||||
)}
|
||||
</Button>
|
||||
</Field>
|
||||
<FieldSeparator>Or continue with</FieldSeparator>
|
||||
<Field>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={isLoading || isGoogleLoading}
|
||||
>
|
||||
{isGoogleLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
{"Signing in..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
width="800px"
|
||||
height="800px"
|
||||
viewBox="-3 0 262 262"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
className="size-4 mr-2"
|
||||
>
|
||||
<path
|
||||
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
|
||||
fill="#EB4335"
|
||||
/>
|
||||
</svg>
|
||||
Login with Google
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<FieldDescription className="text-center">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/sign-up" className="underline underline-offset-4">
|
||||
Sign up
|
||||
</Link>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
46
app/(auth)/sign-up/page.tsx
Normal file
46
app/(auth)/sign-up/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Metadata } from "next";
|
||||
import { SignupForm } from "@/app/(auth)/sign-up/sign-up-form";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign Up",
|
||||
};
|
||||
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<div className="grid h-screen lg:grid-cols-2">
|
||||
<div className="flex flex-col p-4 md:p-6 h-screen overflow-hidden">
|
||||
<div className="flex justify-center md:justify-start flex-shrink-0 mb-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 font-semibold text-lg"
|
||||
>
|
||||
<div className=" text-primary-foreground flex size-10 items-center justify-center rounded-lg p-2">
|
||||
<Image
|
||||
src="/tooth.svg"
|
||||
alt="Dental U Care"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</div>
|
||||
Dental U Care
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-xs">
|
||||
<SignupForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted relative hidden lg:block h-screen">
|
||||
<Image
|
||||
src="/doctor-image.jpg"
|
||||
alt="Doctor"
|
||||
className="absolute inset-0 h-full w-full object-cover dark:brightness-80"
|
||||
priority
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
414
app/(auth)/sign-up/sign-up-form.tsx
Normal file
414
app/(auth)/sign-up/sign-up-form.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-session/auth-client";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function SignupForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"form">) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
const [showVerifyNotice, setShowVerifyNotice] = useState(false);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
const [resendSuccess, setResendSuccess] = useState(false);
|
||||
|
||||
function togglePassword(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
e.preventDefault();
|
||||
setShowPassword((s) => !s);
|
||||
}
|
||||
|
||||
function toggleConfirm(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
e.preventDefault();
|
||||
setShowConfirm((s) => !s);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
toast.error("Passwords don't match", {
|
||||
description: "Please make sure both passwords are the same.",
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!/[^A-Za-z0-9]/.test(password)) {
|
||||
toast.error("Password must contain at least one special character", {
|
||||
description:
|
||||
"Please include at least one special character in your password.",
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// Validate password length
|
||||
if (password.length < 8) {
|
||||
toast.error("Password too short", {
|
||||
description: "Password must be at least 8 characters long.",
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await authClient.signUp.email({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (error.status === 403) {
|
||||
setShowVerifyNotice(true);
|
||||
toast.error("Please verify your email address", {
|
||||
description: "Check your inbox for the verification link.",
|
||||
});
|
||||
} else {
|
||||
setShowVerifyNotice(false);
|
||||
toast.error("Sign up failed", {
|
||||
description:
|
||||
error.message || "Unable to create account. Please try again.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setShowVerifyNotice(false);
|
||||
toast.success("Account created successfully!", {
|
||||
description: "Please check your email to verify your account.",
|
||||
});
|
||||
// Redirect to login page after successful signup using full page reload
|
||||
setTimeout(() => {
|
||||
window.location.href = "/sign-in";
|
||||
}, 2000);
|
||||
}
|
||||
} catch {
|
||||
toast.error("An unexpected error occurred", {
|
||||
description: "Please try again later.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResendVerification(
|
||||
e: React.MouseEvent<HTMLButtonElement>
|
||||
) {
|
||||
e.preventDefault();
|
||||
setResendLoading(true);
|
||||
setResendSuccess(false);
|
||||
try {
|
||||
const res = await fetch("/api/auth/resend-verification", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setResendSuccess(true);
|
||||
toast.success("Verification email sent!", {
|
||||
description: "Check your inbox for the verification link.",
|
||||
});
|
||||
} else {
|
||||
toast.error("Failed to resend verification email.");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to resend verification email.");
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleSignUp() {
|
||||
try {
|
||||
setIsGoogleLoading(true);
|
||||
// Google OAuth will redirect to Google, then back to callback
|
||||
// The onAfterSignUp hook in auth.ts assigns "patient" role to new users
|
||||
// After callback, the auth layout redirects to the role-specific dashboard
|
||||
await authClient.signIn.social({
|
||||
provider: "google",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Google sign-up failed:", error);
|
||||
toast.error("Google sign-up failed", {
|
||||
description: "Please try again.",
|
||||
});
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className={cn("flex flex-col gap-3", className)}
|
||||
{...props}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{showVerifyNotice && (
|
||||
<div className="bg-yellow-100 border border-yellow-300 text-yellow-800 rounded p-3 text-center mb-2">
|
||||
<div className="mb-2">
|
||||
Your email is not verified. Please check your inbox for the
|
||||
verification link.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="underline text-sm text-blue-700 disabled:opacity-60"
|
||||
onClick={handleResendVerification}
|
||||
disabled={resendLoading || resendSuccess}
|
||||
>
|
||||
{resendLoading
|
||||
? "Resending..."
|
||||
: resendSuccess
|
||||
? "Verification Sent!"
|
||||
: "Resend Verification Email"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<FieldGroup className="gap-3">
|
||||
<div className="flex flex-col items-center text-center mb-2">
|
||||
<h1 className="text-2xl font-bold">Create your account</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Fill in the form below to create your account
|
||||
</p>
|
||||
</div>
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor="name" className="text-xs">
|
||||
Full Name
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor="email" className="text-xs">
|
||||
Email
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="e.g m@gmail.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
required
|
||||
className="h-9"
|
||||
/>
|
||||
<FieldDescription className="text-xs leading-tight">
|
||||
We'll use this to contact you.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor="password" className="text-xs">
|
||||
Password
|
||||
</FieldLabel>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
minLength={8}
|
||||
required
|
||||
className="h-9"
|
||||
/>
|
||||
<button
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
onClick={togglePassword}
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 text-sm opacity-70 hover:opacity-100"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.97 9.97 0 012.175-5.875M6.343 6.343A9.97 9.97 0 0112 5c5.523 0 10 4.477 10 10 0 1.042-.161 2.045-.463 2.998M3 3l18 18"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<FieldDescription className="text-xs leading-tight">
|
||||
Must be at least 8 characters long.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor="confirm-password" className="text-xs">
|
||||
Confirm Password
|
||||
</FieldLabel>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type={showConfirm ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
minLength={8}
|
||||
required
|
||||
className="h-9"
|
||||
/>
|
||||
<button
|
||||
aria-label={
|
||||
showConfirm ? "Hide confirm password" : "Show confirm password"
|
||||
}
|
||||
onClick={toggleConfirm}
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center p-1 text-sm opacity-70 hover:opacity-100"
|
||||
>
|
||||
{showConfirm ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.97 9.97 0 012.175-5.875M6.343 6.343A9.97 9.97 0 0112 5c5.523 0 10 4.477 10 10 0 1.042-.161 2.045-.463 2.998M3 3l18 18"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<FieldDescription className="text-xs leading-tight">
|
||||
Please confirm your password.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Field className="gap-1">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || isGoogleLoading}
|
||||
className="h-9"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
"Create Account"
|
||||
)}
|
||||
</Button>
|
||||
</Field>
|
||||
<FieldSeparator>Or continue with</FieldSeparator>
|
||||
<Field>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={handleGoogleSignUp}
|
||||
disabled={isLoading || isGoogleLoading}
|
||||
>
|
||||
<svg
|
||||
width="800px"
|
||||
height="800px"
|
||||
viewBox="-3 0 262 262"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
>
|
||||
<path
|
||||
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
|
||||
fill="#EB4335"
|
||||
/>
|
||||
</svg>
|
||||
{isGoogleLoading ? "Signing up..." : "Sign up with Google"}
|
||||
</Button>
|
||||
<FieldDescription className="px-6 text-center">
|
||||
Already have an account? <Link href="/sign-in">Sign in</Link>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
18
app/(main)/admin/action.ts
Normal file
18
app/(main)/admin/action.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "@/lib/auth-session/get-session";
|
||||
import { forbidden, unauthorized } from "next/navigation";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
|
||||
export async function deleteApplication() {
|
||||
const session = await getServerSession();
|
||||
const user = session?.user;
|
||||
|
||||
if (!user) unauthorized();
|
||||
|
||||
if (user.role !== "admin") forbidden();
|
||||
|
||||
// Delete app...
|
||||
|
||||
await setTimeout(800);
|
||||
}
|
||||
49
app/(main)/admin/appointment-management/page.tsx
Normal file
49
app/(main)/admin/appointment-management/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { AdminAppointmentsTable } from "@/components/admin/appointments-table";
|
||||
import { requireAdmin } from "@/lib/auth-session/auth-server";
|
||||
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Appointment Management",
|
||||
};
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AppointmentManagementPage() {
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
// Add pagination limit to prevent loading too much data at once
|
||||
// Use safe find to filter out orphaned appointments
|
||||
const appointments = await safeFindManyAppointments({
|
||||
take: 100, // Limit to 100 most recent appointments
|
||||
include: {
|
||||
patient: true,
|
||||
dentist: true,
|
||||
service: true,
|
||||
payment: true,
|
||||
},
|
||||
orderBy: {
|
||||
date: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "admin" }}
|
||||
role="admin"
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Appointment Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage all appointments in the system
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AdminAppointmentsTable appointments={appointments} />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
62
app/(main)/admin/dentist-management/page.tsx
Normal file
62
app/(main)/admin/dentist-management/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { AdminDentistsTable } from "@/components/admin/dentists-table";
|
||||
import { requireAdmin } from "@/lib/auth-session/auth-server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dentist Management",
|
||||
};
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DentistManagementPage() {
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const dentistsData = await prisma.user.findMany({
|
||||
take: 50, // Limit to 50 dentists to prevent excessive data loading
|
||||
where: {
|
||||
role: "dentist",
|
||||
},
|
||||
include: {
|
||||
appointmentsAsDentist: {
|
||||
take: 10, // Limit appointments per dentist to avoid N+1 issue
|
||||
include: {
|
||||
service: true,
|
||||
patient: true,
|
||||
},
|
||||
orderBy: {
|
||||
date: "desc",
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
// Transform the data to match the expected Dentist type
|
||||
const dentists = dentistsData.map((dentist) => ({
|
||||
...dentist,
|
||||
experience: dentist.experience !== null ? String(dentist.experience) : null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "admin" }}
|
||||
role="admin"
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Dentist Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage all dentists in the system
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AdminDentistsTable dentists={dentists} />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
197
app/(main)/admin/page.tsx
Normal file
197
app/(main)/admin/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { ChartAreaInteractive } from "@/components/chart/chart-area-interactive";
|
||||
import { AdminAppointmentsTable } from "@/components/admin/appointments-table";
|
||||
import { SectionCards } from "@/components/layout/section-cards";
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import type { Metadata } from "next";
|
||||
import { requireAdmin } from "@/lib/auth-session/auth-server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dashboard",
|
||||
};
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page() {
|
||||
// Require admin role - will redirect to home page (/) if not admin
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
// Calculate date ranges once for reuse
|
||||
const now = new Date();
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * DAY_IN_MS);
|
||||
const sixtyDaysAgo = new Date(now.getTime() - 60 * DAY_IN_MS);
|
||||
const ninetyDaysAgo = new Date(now.getTime() - 90 * DAY_IN_MS);
|
||||
|
||||
// Run all count queries in parallel for better performance
|
||||
const [
|
||||
totalAppointments,
|
||||
previousAppointments,
|
||||
newPatients,
|
||||
previousPatients,
|
||||
payments,
|
||||
previousPayments,
|
||||
completedAppointments,
|
||||
previousCompleted,
|
||||
appointmentsForChart,
|
||||
appointments,
|
||||
] = await Promise.all([
|
||||
// Total appointments in last 30 days
|
||||
prisma.appointment.count({
|
||||
where: {
|
||||
createdAt: { gte: thirtyDaysAgo },
|
||||
},
|
||||
}),
|
||||
// Previous period appointments for comparison
|
||||
prisma.appointment.count({
|
||||
where: {
|
||||
createdAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
|
||||
},
|
||||
}),
|
||||
// New patients in last 30 days
|
||||
prisma.user.count({
|
||||
where: {
|
||||
role: "patient",
|
||||
createdAt: { gte: thirtyDaysAgo },
|
||||
},
|
||||
}),
|
||||
// Previous period patients
|
||||
prisma.user.count({
|
||||
where: {
|
||||
role: "patient",
|
||||
createdAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
|
||||
},
|
||||
}),
|
||||
// Revenue this month
|
||||
prisma.payment.aggregate({
|
||||
where: {
|
||||
status: "paid",
|
||||
paidAt: { gte: thirtyDaysAgo },
|
||||
},
|
||||
_sum: {
|
||||
amount: true,
|
||||
},
|
||||
}),
|
||||
// Previous period revenue
|
||||
prisma.payment.aggregate({
|
||||
where: {
|
||||
status: "paid",
|
||||
paidAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
|
||||
},
|
||||
_sum: {
|
||||
amount: true,
|
||||
},
|
||||
}),
|
||||
// Calculate completed appointments for satisfaction (mock calculation)
|
||||
prisma.appointment.count({
|
||||
where: {
|
||||
status: "completed",
|
||||
updatedAt: { gte: thirtyDaysAgo },
|
||||
},
|
||||
}),
|
||||
// Previous completed
|
||||
prisma.appointment.count({
|
||||
where: {
|
||||
status: "completed",
|
||||
updatedAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
|
||||
},
|
||||
}),
|
||||
// Fetch appointments for chart (last 90 days)
|
||||
prisma.appointment.findMany({
|
||||
where: {
|
||||
createdAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
select: {
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
}),
|
||||
// Fetch recent appointments for table
|
||||
// Use safe find to filter out orphaned appointments
|
||||
safeFindManyAppointments({
|
||||
take: 20,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
patient: true,
|
||||
dentist: true,
|
||||
service: true,
|
||||
payment: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const revenue = payments._sum.amount || 0;
|
||||
const previousRevenue = previousPayments._sum.amount || 0;
|
||||
|
||||
// Calculate percentage changes
|
||||
const appointmentChange =
|
||||
previousAppointments > 0
|
||||
? ((totalAppointments - previousAppointments) / previousAppointments) *
|
||||
100
|
||||
: 0;
|
||||
const patientChange =
|
||||
previousPatients > 0
|
||||
? ((newPatients - previousPatients) / previousPatients) * 100
|
||||
: 0;
|
||||
const revenueChange =
|
||||
previousRevenue > 0
|
||||
? ((revenue - previousRevenue) / previousRevenue) * 100
|
||||
: 0;
|
||||
const satisfactionChange =
|
||||
previousCompleted > 0
|
||||
? ((completedAppointments - previousCompleted) / previousCompleted) * 100
|
||||
: 0;
|
||||
|
||||
// Mock satisfaction rate (in a real app, this would come from reviews/ratings)
|
||||
const satisfactionRate = 98.5;
|
||||
|
||||
// Group appointments by date for chart
|
||||
const chartData = appointmentsForChart.reduce(
|
||||
(acc: Record<string, number>, appointment) => {
|
||||
const date = appointment.createdAt.toISOString().split("T")[0];
|
||||
acc[date] = (acc[date] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
// Convert to array format for chart
|
||||
const chartDataArray = Object.entries(chartData).map(([date, count]) => ({
|
||||
date,
|
||||
appointments: count,
|
||||
}));
|
||||
|
||||
const dashboardStats = {
|
||||
totalAppointments,
|
||||
appointmentChange,
|
||||
newPatients,
|
||||
patientChange,
|
||||
revenue,
|
||||
revenueChange,
|
||||
satisfactionRate,
|
||||
satisfactionChange,
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "admin" }}
|
||||
role="admin"
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<SectionCards stats={dashboardStats} />
|
||||
<div className="px-4 lg:px-6">
|
||||
<ChartAreaInteractive data={chartDataArray} />
|
||||
</div>
|
||||
<div className="px-4 lg:px-6">
|
||||
<AdminAppointmentsTable appointments={appointments} />
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
62
app/(main)/admin/patient-management/page.tsx
Normal file
62
app/(main)/admin/patient-management/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { AdminPatientsTable } from "@/components/admin/patients-table";
|
||||
import { requireAdmin } from "@/lib/auth-session/auth-server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Patient Management",
|
||||
};
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PatientManagementPage() {
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const patients = await prisma.user.findMany({
|
||||
take: 50, // Limit to 50 patients to prevent excessive data loading
|
||||
where: {
|
||||
role: "patient",
|
||||
},
|
||||
include: {
|
||||
appointmentsAsPatient: {
|
||||
take: 10, // Limit appointments per patient to avoid N+1 issue
|
||||
include: {
|
||||
service: true,
|
||||
dentist: true,
|
||||
},
|
||||
orderBy: {
|
||||
date: "desc",
|
||||
},
|
||||
},
|
||||
payments: {
|
||||
take: 10, // Limit payments per patient
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "admin" }}
|
||||
role="admin"
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Patient Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage all patients in the system
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AdminPatientsTable patients={patients} />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
53
app/(main)/admin/service-management/page.tsx
Normal file
53
app/(main)/admin/service-management/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { AdminServicesTable } from "@/components/admin/services-table";
|
||||
import { requireAdmin } from "@/lib/auth-session/auth-server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Service Management",
|
||||
};
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ServiceManagementPage() {
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const servicesData = await prisma.service.findMany({
|
||||
take: 100, // Limit to 100 services
|
||||
include: {
|
||||
appointments: {
|
||||
take: 5, // Limit appointments per service to avoid N+1 issue
|
||||
orderBy: {
|
||||
date: "desc",
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
name: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
// Transform the data to match the expected Service type
|
||||
const services = servicesData.map((service) => ({
|
||||
...service,
|
||||
description: service.description ?? "",
|
||||
}));
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "admin" }}
|
||||
role="admin"
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Service Management</h1>
|
||||
<p className="text-muted-foreground">Manage all dental services</p>
|
||||
</div>
|
||||
|
||||
<AdminServicesTable services={services} />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
19
app/(main)/admin/settings/page.tsx
Normal file
19
app/(main)/admin/settings/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { requireAdmin } from "@/lib/auth-session/auth-server";
|
||||
import { AdminSettingsContent } from "@/components/admin/settings-content";
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminSettingsPage() {
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "admin" }}
|
||||
role="admin"
|
||||
>
|
||||
<AdminSettingsContent user={{ ...user, role: user.role || "admin" }} />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
42
app/(main)/admin/user-management/page.tsx
Normal file
42
app/(main)/admin/user-management/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { AdminUsersTable } from "@/components/admin/users-table";
|
||||
import { requireAdmin } from "@/lib/auth-session/auth-server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "User Management",
|
||||
};
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function UserManagementPage() {
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const usersRaw = await prisma.user.findMany({
|
||||
take: 100, // Limit to 100 most recent users
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
const users = usersRaw.map((u) => ({ ...u, role: u.role ?? undefined }));
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "admin" }}
|
||||
role="admin"
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">User Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage all users in the system
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AdminUsersTable users={users} />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
57
app/(main)/dentist/appointments/page.tsx
Normal file
57
app/(main)/dentist/appointments/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { DentistAppointmentsList } from "@/components/dentist/appointments-list";
|
||||
import { requireDentist } from "@/lib/auth-session/auth-server";
|
||||
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Appointments - Dentist",
|
||||
};
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DentistAppointmentsPage() {
|
||||
const { user } = await requireDentist();
|
||||
|
||||
const appointmentsData = await safeFindManyAppointments({
|
||||
take: 100, // Limit to 100 most recent appointments
|
||||
where: {
|
||||
dentistId: user.id,
|
||||
},
|
||||
include: {
|
||||
patient: true,
|
||||
service: true,
|
||||
payment: true,
|
||||
},
|
||||
orderBy: {
|
||||
date: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
const appointments = appointmentsData.map((appointment) => ({
|
||||
...appointment,
|
||||
service: {
|
||||
...appointment.service,
|
||||
price: parseFloat(appointment.service.price),
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "dentist" }}
|
||||
role="dentist"
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Appointments</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your patient appointments
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DentistAppointmentsList appointments={appointments} />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
294
app/(main)/dentist/page.tsx
Normal file
294
app/(main)/dentist/page.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar, Clock, Users, CheckCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { requireDentist } from "@/lib/auth-session/auth-server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dentist Dashboard",
|
||||
};
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DentistDashboard() {
|
||||
// Require dentist role - will redirect to appropriate page if not dentist
|
||||
const { user } = await requireDentist();
|
||||
|
||||
// Calculate date ranges
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const now = new Date();
|
||||
|
||||
// Run all queries in parallel for better performance
|
||||
const [
|
||||
todayAppointments,
|
||||
pendingAppointments,
|
||||
totalPatients,
|
||||
completedAppointments,
|
||||
upcomingAppointments,
|
||||
] = await Promise.all([
|
||||
// Fetch today's appointments
|
||||
safeFindManyAppointments({
|
||||
where: {
|
||||
dentistId: user.id,
|
||||
date: {
|
||||
gte: today,
|
||||
lt: tomorrow,
|
||||
},
|
||||
status: {
|
||||
in: ["pending", "confirmed"],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
patient: true,
|
||||
service: true,
|
||||
},
|
||||
orderBy: {
|
||||
timeSlot: "asc",
|
||||
},
|
||||
}),
|
||||
// Fetch pending appointments
|
||||
safeFindManyAppointments({
|
||||
where: {
|
||||
dentistId: user.id,
|
||||
status: "pending",
|
||||
date: {
|
||||
gte: now,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
patient: true,
|
||||
service: true,
|
||||
},
|
||||
orderBy: {
|
||||
date: "asc",
|
||||
},
|
||||
take: 5,
|
||||
}),
|
||||
// Get total unique patients
|
||||
prisma.appointment.groupBy({
|
||||
by: ["patientId"],
|
||||
where: {
|
||||
dentistId: user.id,
|
||||
},
|
||||
}),
|
||||
// Get completed appointments count
|
||||
prisma.appointment.count({
|
||||
where: {
|
||||
dentistId: user.id,
|
||||
status: "completed",
|
||||
},
|
||||
}),
|
||||
// Get upcoming appointments count
|
||||
prisma.appointment.count({
|
||||
where: {
|
||||
dentistId: user.id,
|
||||
status: {
|
||||
in: ["pending", "confirmed"],
|
||||
},
|
||||
date: {
|
||||
gte: now,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "dentist" }}
|
||||
role="dentist"
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Welcome, Dr. {user.name}!</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your schedule and patients
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Today's Appointments
|
||||
</CardTitle>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{todayAppointments.length}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scheduled for today
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Upcoming</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{upcomingAppointments}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Future appointments
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Patients
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalPatients.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Unique patients</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completed</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{completedAppointments}</div>
|
||||
<p className="text-xs text-muted-foreground">All time</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Link href="/dentist/appointments">
|
||||
<Button className="w-full h-20" variant="outline">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Calendar className="h-6 w-6" />
|
||||
<span>View All Appointments</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/dentist/schedule">
|
||||
<Button className="w-full h-20" variant="outline">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Clock className="h-6 w-6" />
|
||||
<span>Manage Schedule</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/dentist/patients">
|
||||
<Button className="w-full h-20" variant="outline">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users className="h-6 w-6" />
|
||||
<span>Patient Records</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Today's Schedule */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Today's Schedule</CardTitle>
|
||||
<CardDescription>{today.toLocaleDateString()}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{todayAppointments.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
No appointments scheduled for today
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{todayAppointments.map((appointment) => (
|
||||
<div
|
||||
key={appointment.id}
|
||||
className="flex items-center justify-between border-b pb-4 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{appointment.patient.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{appointment.service.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{appointment.timeSlot}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline">
|
||||
View Details
|
||||
</Button>
|
||||
{appointment.status === "pending" && (
|
||||
<Button size="sm">Confirm</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pending Appointments */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pending Appointments</CardTitle>
|
||||
<CardDescription>
|
||||
Appointments awaiting confirmation
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pendingAppointments.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
No pending appointments
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{pendingAppointments.map((appointment) => (
|
||||
<div
|
||||
key={appointment.id}
|
||||
className="flex items-center justify-between border-b pb-4 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{appointment.patient.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{appointment.service.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(appointment.date).toLocaleDateString()} at{" "}
|
||||
{appointment.timeSlot}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm">Accept</Button>
|
||||
<Button size="sm" variant="destructive">
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
64
app/(main)/dentist/patients/page.tsx
Normal file
64
app/(main)/dentist/patients/page.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { DentistPatientsTable } from "@/components/dentist/patients-table";
|
||||
import { requireDentist } from "@/lib/auth-session/auth-server";
|
||||
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Patient Records",
|
||||
};
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DentistPatientsPage() {
|
||||
const { user } = await requireDentist();
|
||||
|
||||
// Get all unique patients who have appointments with this dentist
|
||||
// Use safe find to filter out orphaned appointments
|
||||
const appointments = await safeFindManyAppointments({
|
||||
take: 200, // Limit to prevent excessive data loading
|
||||
where: {
|
||||
dentistId: user.id,
|
||||
},
|
||||
include: {
|
||||
patient: true,
|
||||
service: true,
|
||||
},
|
||||
orderBy: {
|
||||
date: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
// Group by patient
|
||||
const patientsMap = new Map();
|
||||
appointments.forEach((apt) => {
|
||||
if (!patientsMap.has(apt.patient.id)) {
|
||||
patientsMap.set(apt.patient.id, {
|
||||
...apt.patient,
|
||||
appointments: [],
|
||||
});
|
||||
}
|
||||
patientsMap.get(apt.patient.id).appointments.push(apt);
|
||||
});
|
||||
|
||||
const patients = Array.from(patientsMap.values());
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "dentist" }}
|
||||
role="dentist"
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Patient Records</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View your patients' information and history
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DentistPatientsTable patients={patients} />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
49
app/(main)/dentist/schedule/page.tsx
Normal file
49
app/(main)/dentist/schedule/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { requireDentist } from "@/lib/auth-session/auth-server";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Manage Schedule",
|
||||
};
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DentistSchedulePage() {
|
||||
const { user } = await requireDentist();
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "dentist" }}
|
||||
role="dentist"
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Manage Schedule</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Set your working hours and availability
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Working Hours</CardTitle>
|
||||
<CardDescription>Configure your weekly schedule</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
Schedule management feature coming soon
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
44
app/(main)/dentist/settings/page.tsx
Normal file
44
app/(main)/dentist/settings/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { requireAuth } from "@/lib/auth-session/auth-server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { UserSettingsContent } from "@/components/user/settings-content";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DentistSettingsPage() {
|
||||
const session = await requireAuth();
|
||||
|
||||
if (session.user.role !== "dentist") {
|
||||
redirect("/forbidden");
|
||||
}
|
||||
|
||||
// Fetch full user data
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
image: true,
|
||||
dateOfBirth: true,
|
||||
address: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
redirect("/sign-in");
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "dentist" }}
|
||||
role="dentist"
|
||||
>
|
||||
<UserSettingsContent user={{ ...user, role: user.role || "dentist" }} />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
78
app/(main)/patient/appointments/page.tsx
Normal file
78
app/(main)/patient/appointments/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { AppointmentsList } from "@/components/patient/appointments-list";
|
||||
import { requirePatient } from "@/lib/auth-session/auth-server";
|
||||
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
|
||||
import type { Metadata } from "next";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { CheckCircle } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "My Appointments",
|
||||
};
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface AppointmentsPageProps {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export default async function AppointmentsPage({
|
||||
searchParams,
|
||||
}: AppointmentsPageProps) {
|
||||
const { user } = await requirePatient();
|
||||
|
||||
const appointmentsData = await safeFindManyAppointments({
|
||||
take: 50, // Limit to 50 most recent appointments
|
||||
where: {
|
||||
patientId: user.id,
|
||||
},
|
||||
include: {
|
||||
dentist: true,
|
||||
service: true,
|
||||
payment: true,
|
||||
},
|
||||
orderBy: {
|
||||
date: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
const appointments = appointmentsData.map((appointment) => ({
|
||||
...appointment,
|
||||
service: {
|
||||
...appointment.service,
|
||||
price: appointment.service.price, // Keep price as is (can be string or number)
|
||||
},
|
||||
}));
|
||||
|
||||
const params = await searchParams;
|
||||
const showSuccess = params.success === "true";
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "patient" }}
|
||||
role="patient"
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">My Appointments</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View and manage your dental appointments
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showSuccess && (
|
||||
<Alert className="border-green-200 bg-green-50 dark:bg-green-950/30">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription className="text-green-800 dark:text-green-200">
|
||||
Your appointment has been successfully booked! Check your email
|
||||
for confirmation.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<AppointmentsList appointments={appointments} />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
93
app/(main)/patient/book-appointment/page.tsx
Normal file
93
app/(main)/patient/book-appointment/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type React from "react";
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import BookingForm from "@/components/patient/booking-form";
|
||||
import { requirePatient } from "@/lib/auth-session/auth-server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import type { Metadata } from "next";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { XCircle } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Book Appointment",
|
||||
};
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface BookAppointmentPageProps {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export default async function BookAppointmentPage({
|
||||
searchParams,
|
||||
}: BookAppointmentPageProps) {
|
||||
const { user } = await requirePatient();
|
||||
|
||||
// Fetch available services from database
|
||||
const servicesFromDb = await prisma.service.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
// Transform services to match component expectations
|
||||
const services = servicesFromDb.map((service) => ({
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
price: service.price,
|
||||
duration: service.duration,
|
||||
category: service.category,
|
||||
description: service.description || undefined,
|
||||
}));
|
||||
|
||||
// Fetch available dentists
|
||||
const dentists = await prisma.user.findMany({
|
||||
where: {
|
||||
role: "dentist",
|
||||
isAvailable: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
specialization: true,
|
||||
image: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
// Transform dentists data to match component expectations
|
||||
const transformedDentists = dentists.map((dentist) => ({
|
||||
...dentist,
|
||||
specialization: dentist.specialization || undefined,
|
||||
image: dentist.image || undefined,
|
||||
}));
|
||||
|
||||
const params = await searchParams;
|
||||
const showCanceled = params.canceled === "true";
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "patient" }}
|
||||
role="patient"
|
||||
>
|
||||
{showCanceled && (
|
||||
<Alert className="m-4 md:m-8 border-red-200 bg-red-50 dark:bg-red-950/30">
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
<AlertDescription className="text-red-800 dark:text-red-200">
|
||||
Payment was canceled. You can try booking again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<BookingForm
|
||||
services={services}
|
||||
dentists={transformedDentists}
|
||||
patientId={user.id}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
176
app/(main)/patient/health-records/page.tsx
Normal file
176
app/(main)/patient/health-records/page.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { requirePatient } from "@/lib/auth-session/auth-server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import type { Metadata } from "next";
|
||||
import { FileText, Calendar, User } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Health Records",
|
||||
};
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function HealthRecordsPage() {
|
||||
const { user } = await requirePatient();
|
||||
|
||||
const userDetails = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
include: {
|
||||
appointmentsAsPatient: {
|
||||
where: {
|
||||
status: "completed",
|
||||
},
|
||||
include: {
|
||||
service: true,
|
||||
dentist: true,
|
||||
},
|
||||
orderBy: {
|
||||
date: "desc",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "patient" }}
|
||||
role="patient"
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Health Records</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Your medical history and treatment records
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Personal Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Personal Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Full Name
|
||||
</p>
|
||||
<p className="text-base">{userDetails?.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Email</p>
|
||||
<p className="text-base">{userDetails?.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Phone</p>
|
||||
<p className="text-base">
|
||||
{userDetails?.phone || "Not provided"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Date of Birth
|
||||
</p>
|
||||
<p className="text-base">
|
||||
{userDetails?.dateOfBirth
|
||||
? new Date(userDetails.dateOfBirth).toLocaleDateString()
|
||||
: "Not provided"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Address
|
||||
</p>
|
||||
<p className="text-base">
|
||||
{userDetails?.address || "Not provided"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Medical History */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Medical History
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Important medical information for your dentist
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{userDetails?.medicalHistory ? (
|
||||
<p className="text-base whitespace-pre-wrap">
|
||||
{userDetails.medicalHistory}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
No medical history recorded
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Treatment History */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Treatment History
|
||||
</CardTitle>
|
||||
<CardDescription>Completed dental procedures</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!userDetails?.appointmentsAsPatient ||
|
||||
userDetails.appointmentsAsPatient.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
No treatment history
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{userDetails.appointmentsAsPatient.map((appointment) => (
|
||||
<div
|
||||
key={appointment.id}
|
||||
className="border-b pb-4 last:border-0"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{appointment.service.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dr. {appointment.dentist.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(appointment.date).toLocaleDateString()} at{" "}
|
||||
{appointment.timeSlot}
|
||||
</p>
|
||||
{appointment.notes && (
|
||||
<p className="text-sm mt-2">
|
||||
<span className="font-medium">Notes:</span>{" "}
|
||||
{appointment.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
92
app/(main)/patient/page.tsx
Normal file
92
app/(main)/patient/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { PatientSectionCards } from "@/components/patient/section-cards";
|
||||
import { requirePatient } from "@/lib/auth-session/auth-server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Patient Dashboard",
|
||||
};
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PatientDashboard() {
|
||||
// Require patient role - will redirect to appropriate page if not patient
|
||||
const { user } = await requirePatient();
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Run all queries in parallel for better performance
|
||||
const [
|
||||
upcomingAppointmentsCount,
|
||||
completedAppointmentsCount,
|
||||
totalSpentResult,
|
||||
pendingPaymentsResult,
|
||||
] = await Promise.all([
|
||||
// Fetch upcoming appointments count
|
||||
prisma.appointment.count({
|
||||
where: {
|
||||
patientId: user.id,
|
||||
date: {
|
||||
gte: now,
|
||||
},
|
||||
status: {
|
||||
in: ["pending", "confirmed"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Fetch completed appointments count
|
||||
prisma.appointment.count({
|
||||
where: {
|
||||
patientId: user.id,
|
||||
status: "completed",
|
||||
},
|
||||
}),
|
||||
// Calculate total spent (paid payments)
|
||||
prisma.payment.aggregate({
|
||||
where: {
|
||||
userId: user.id,
|
||||
status: "paid",
|
||||
},
|
||||
_sum: {
|
||||
amount: true,
|
||||
},
|
||||
}),
|
||||
// Calculate pending payments
|
||||
prisma.payment.aggregate({
|
||||
where: {
|
||||
userId: user.id,
|
||||
status: "pending",
|
||||
},
|
||||
_sum: {
|
||||
amount: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const patientStats = {
|
||||
upcomingAppointments: upcomingAppointmentsCount,
|
||||
completedAppointments: completedAppointmentsCount,
|
||||
totalSpent: totalSpentResult._sum.amount || 0,
|
||||
pendingPayments: pendingPaymentsResult._sum.amount || 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "patient" }}
|
||||
role="patient"
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<div className="px-4 lg:px-6">
|
||||
<h1 className="text-3xl font-bold">Welcome back, {user.name}!</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your appointments and health records
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PatientSectionCards stats={patientStats} />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
52
app/(main)/patient/payments/page.tsx
Normal file
52
app/(main)/patient/payments/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { PaymentHistory } from "@/components/patient/payment-history";
|
||||
import { requirePatient } from "@/lib/auth-session/auth-server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Payment History",
|
||||
};
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PaymentsPage() {
|
||||
const { user } = await requirePatient();
|
||||
|
||||
const payments = await prisma.payment.findMany({
|
||||
take: 50, // Limit to 50 most recent payments
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
include: {
|
||||
appointment: {
|
||||
include: {
|
||||
service: true,
|
||||
dentist: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "patient" }}
|
||||
role="patient"
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 px-4 lg:px-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Payment History</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View your payment transactions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PaymentHistory payments={payments} />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
44
app/(main)/patient/settings/page.tsx
Normal file
44
app/(main)/patient/settings/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { requireAuth } from "@/lib/auth-session/auth-server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { UserSettingsContent } from "@/components/user/settings-content";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
|
||||
// Force dynamic rendering since this page uses authentication (headers)
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function UserSettingsPage() {
|
||||
const session = await requireAuth();
|
||||
|
||||
if (session.user.role !== "patient") {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
// Fetch full user data
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
image: true,
|
||||
dateOfBirth: true,
|
||||
address: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
redirect("/sign-in");
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
user={{ ...user, role: user.role || "patient" }}
|
||||
role="patient"
|
||||
>
|
||||
<UserSettingsContent user={{ ...user, role: user.role || "patient" }} />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
9
app/(main)/profile/page.tsx
Normal file
9
app/(main)/profile/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import {Metadata} from "next"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Profile",
|
||||
};
|
||||
|
||||
export default function Profile (){
|
||||
return
|
||||
}
|
||||
75
app/api/appointments/[id]/edit/route.ts
Normal file
75
app/api/appointments/[id]/edit/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import { auth } from "@/lib/auth-session/auth";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await import("next/headers").then((mod) => mod.headers()),
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admin can edit appointments
|
||||
if (session.user.role !== "admin") {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden: Admin access required" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { date, timeSlot, status, notes } = body;
|
||||
|
||||
// Validate that the appointment exists
|
||||
const existingAppointment = await prisma.appointment.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingAppointment) {
|
||||
return NextResponse.json(
|
||||
{ error: "Appointment not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build update data object
|
||||
const updateData: Prisma.AppointmentUpdateInput = {};
|
||||
|
||||
if (date !== undefined) updateData.date = new Date(date);
|
||||
if (timeSlot !== undefined) updateData.timeSlot = timeSlot;
|
||||
if (status !== undefined) updateData.status = status;
|
||||
if (notes !== undefined) updateData.notes = notes;
|
||||
|
||||
// Update the appointment
|
||||
const updatedAppointment = await prisma.appointment.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
patient: true,
|
||||
dentist: true,
|
||||
service: true,
|
||||
payment: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
appointment: updatedAppointment,
|
||||
message: "Appointment updated successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating appointment:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update appointment" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
139
app/api/appointments/[id]/route.ts
Normal file
139
app/api/appointments/[id]/route.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import { auth } from "@/lib/auth-session/auth";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { status, cancelReason, date, timeSlot } = body;
|
||||
const { id } = await params;
|
||||
|
||||
const appointment = await prisma.appointment.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
patient: true,
|
||||
dentist: true,
|
||||
service: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
return NextResponse.json(
|
||||
{ error: "Appointment not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update appointment
|
||||
const updatedAppointment = await prisma.appointment.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(status && { status }),
|
||||
...(cancelReason && { cancelReason }),
|
||||
...(date && { date: new Date(date) }),
|
||||
...(timeSlot && { timeSlot }),
|
||||
},
|
||||
include: {
|
||||
patient: true,
|
||||
dentist: true,
|
||||
service: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create notifications based on action
|
||||
if (status === "cancelled") {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: appointment.patientId,
|
||||
title: "Appointment Cancelled",
|
||||
message: `Your appointment for ${appointment.service.name} on ${new Date(appointment.date).toLocaleDateString()} has been cancelled.`,
|
||||
type: "email",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: appointment.dentistId,
|
||||
title: "Appointment Cancelled",
|
||||
message: `Appointment with ${appointment.patient.name} on ${new Date(appointment.date).toLocaleDateString()} has been cancelled.`,
|
||||
type: "email",
|
||||
},
|
||||
});
|
||||
} else if (status === "confirmed") {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: appointment.patientId,
|
||||
title: "Appointment Confirmed",
|
||||
message: `Your appointment for ${appointment.service.name} on ${new Date(appointment.date).toLocaleDateString()} has been confirmed.`,
|
||||
type: "email",
|
||||
},
|
||||
});
|
||||
} else if (date || timeSlot) {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: appointment.patientId,
|
||||
title: "Appointment Rescheduled",
|
||||
message: `Your appointment has been rescheduled to ${new Date(updatedAppointment.date).toLocaleDateString()} at ${updatedAppointment.timeSlot}.`,
|
||||
type: "email",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: appointment.dentistId,
|
||||
title: "Appointment Rescheduled",
|
||||
message: `Appointment with ${appointment.patient.name} has been rescheduled to ${new Date(updatedAppointment.date).toLocaleDateString()} at ${updatedAppointment.timeSlot}.`,
|
||||
type: "email",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(updatedAppointment);
|
||||
} catch (error) {
|
||||
console.error("Error updating appointment:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update appointment" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
await prisma.appointment.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Appointment deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting appointment:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete appointment" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
270
app/api/appointments/book/route.ts
Normal file
270
app/api/appointments/book/route.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import { auth } from "@/lib/auth-session/auth";
|
||||
import { Resend } from "resend";
|
||||
import { createElement } from "react";
|
||||
import DentalInvoice from "@/components/emails/email-bookings";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { appointmentData } = body;
|
||||
|
||||
if (!appointmentData) {
|
||||
return NextResponse.json(
|
||||
{ error: "Appointment data is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
interface ServiceItem {
|
||||
qty: number;
|
||||
description: string;
|
||||
unitPrice: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const { patientId, personalInfo, appointment, services, specialRequests } =
|
||||
appointmentData as {
|
||||
patientId: string;
|
||||
personalInfo: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
contactNumber?: string;
|
||||
};
|
||||
appointment: {
|
||||
dentistId: string;
|
||||
dentistName: string;
|
||||
date: string;
|
||||
time: string;
|
||||
};
|
||||
services: ServiceItem[];
|
||||
specialRequests: string;
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (
|
||||
!patientId ||
|
||||
!appointment.dentistId ||
|
||||
!appointment.date ||
|
||||
!appointment.time
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required appointment fields" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if time slot is already booked
|
||||
const existingAppointment = await prisma.appointment.findFirst({
|
||||
where: {
|
||||
dentistId: appointment.dentistId,
|
||||
date: new Date(appointment.date),
|
||||
timeSlot: appointment.time,
|
||||
status: {
|
||||
in: ["pending", "confirmed"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAppointment) {
|
||||
return NextResponse.json(
|
||||
{ error: "This time slot is already booked" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create appointments for each service
|
||||
const createdAppointments = [];
|
||||
|
||||
for (const service of services) {
|
||||
if (service.qty > 0 && service.description) {
|
||||
// Find the service in database
|
||||
const dbService = await prisma.service.findFirst({
|
||||
where: { name: service.description },
|
||||
});
|
||||
|
||||
if (dbService) {
|
||||
const newAppointment = await prisma.appointment.create({
|
||||
data: {
|
||||
patientId,
|
||||
dentistId: appointment.dentistId,
|
||||
serviceId: dbService.id,
|
||||
date: new Date(appointment.date),
|
||||
timeSlot: appointment.time,
|
||||
notes: specialRequests || null,
|
||||
status: "pending",
|
||||
},
|
||||
include: {
|
||||
patient: true,
|
||||
dentist: true,
|
||||
service: true,
|
||||
},
|
||||
});
|
||||
|
||||
createdAppointments.push(newAppointment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (createdAppointments.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "No valid services selected" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Send confirmation email to patient with professional invoice template
|
||||
// Generate invoice number
|
||||
const invoiceNumber = `INV-${Date.now()}-${Math.random().toString(36).substring(7).toUpperCase()}`;
|
||||
const invoiceDate = new Date().toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
const dueDate = new Date(
|
||||
Date.now() + 30 * 24 * 60 * 60 * 1000
|
||||
).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
// Format appointment date and time
|
||||
const formattedAppointmentDate = new Date(
|
||||
appointment.date
|
||||
).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
// Calculate next appointment (6 months from now for regular checkup)
|
||||
const nextApptDate = new Date(appointment.date);
|
||||
nextApptDate.setMonth(nextApptDate.getMonth() + 6);
|
||||
const nextAppointmentDate = nextApptDate.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
// Calculate total duration (assuming each service takes 60 minutes)
|
||||
const totalDuration = services
|
||||
.filter((s) => s.qty > 0)
|
||||
.reduce((sum, s) => sum + s.qty * 60, 0);
|
||||
|
||||
// Calculate financial totals
|
||||
const subtotal = services
|
||||
.filter((s) => s.qty > 0)
|
||||
.reduce((sum, s) => sum + s.total, 0);
|
||||
const tax = subtotal * 0.12; // 12% tax
|
||||
const totalDue = subtotal + tax;
|
||||
|
||||
// Filter services with qty > 0 for email
|
||||
const activeServices = services.filter((s) => s.qty > 0);
|
||||
|
||||
try {
|
||||
console.log("Attempting to send email to:", personalInfo.email);
|
||||
console.log(
|
||||
"From address:",
|
||||
`${process.env.EMAIL_SENDER_NAME} <${process.env.EMAIL_SENDER_ADDRESS}>`
|
||||
);
|
||||
|
||||
const emailResult = await resend.emails.send({
|
||||
from: `${process.env.EMAIL_SENDER_NAME} <${process.env.EMAIL_SENDER_ADDRESS}>`,
|
||||
to: personalInfo.email,
|
||||
subject: `Appointment Confirmation - Invoice #${invoiceNumber}`,
|
||||
react: createElement(DentalInvoice, {
|
||||
invoiceNumber,
|
||||
invoiceDate,
|
||||
dueDate,
|
||||
patientName: `${personalInfo.firstName} ${personalInfo.lastName}`,
|
||||
patientAddress: personalInfo.address || "N/A",
|
||||
patientCity: personalInfo.city || "N/A",
|
||||
patientPhone: personalInfo.contactNumber || "N/A",
|
||||
patientEmail: personalInfo.email,
|
||||
bookingId: createdAppointments[0]?.id || "PENDING",
|
||||
appointmentDate: formattedAppointmentDate,
|
||||
appointmentTime: appointment.time,
|
||||
doctorName: appointment.dentistName,
|
||||
treatmentRoom: "Room 1",
|
||||
appointmentDuration: `${totalDuration} minutes`,
|
||||
reasonForVisit:
|
||||
specialRequests ||
|
||||
services
|
||||
.filter((s) => s.qty > 0)
|
||||
.map((s) => s.description)
|
||||
.join(", "),
|
||||
pdfDownloadUrl: `${process.env.NEXT_PUBLIC_APP_URL}/patient/appointments`,
|
||||
paymentStatus: "Pending Payment",
|
||||
nextAppointmentDate,
|
||||
nextAppointmentTime: appointment.time,
|
||||
nextAppointmentPurpose: "Regular Dental Checkup & Cleaning",
|
||||
services: activeServices,
|
||||
subtotal,
|
||||
tax,
|
||||
totalDue,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("Email sent successfully:", emailResult);
|
||||
} catch (emailError) {
|
||||
console.error("Error sending email:", emailError);
|
||||
console.error(
|
||||
"Email error details:",
|
||||
JSON.stringify(emailError, null, 2)
|
||||
);
|
||||
// Don't fail the appointment creation if email fails
|
||||
}
|
||||
|
||||
// Create notification for patient
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: patientId,
|
||||
title: "Appointment Booked",
|
||||
message: `Your appointment with Dr. ${appointment.dentistName} has been booked for ${new Date(appointment.date).toLocaleDateString()} at ${appointment.time}`,
|
||||
type: "email",
|
||||
},
|
||||
});
|
||||
|
||||
// Create notification for dentist
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: appointment.dentistId,
|
||||
title: "New Appointment",
|
||||
message: `New appointment request from ${personalInfo.firstName} ${personalInfo.lastName} for ${new Date(appointment.date).toLocaleDateString()} at ${appointment.time}`,
|
||||
type: "email",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
appointments: createdAppointments,
|
||||
message:
|
||||
"Appointment booked successfully! Check your email for confirmation.",
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error booking appointment:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to book appointment" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
165
app/api/appointments/route.ts
Normal file
165
app/api/appointments/route.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import { auth } from "@/lib/auth-session/auth";
|
||||
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { patientId, dentistId, serviceId, date, timeSlot, notes } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!patientId || !dentistId || !serviceId || !date || !timeSlot) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if time slot is already booked
|
||||
const existingAppointment = await prisma.appointment.findFirst({
|
||||
where: {
|
||||
dentistId,
|
||||
date: new Date(date),
|
||||
timeSlot,
|
||||
status: {
|
||||
in: ["pending", "confirmed"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAppointment) {
|
||||
return NextResponse.json(
|
||||
{ error: "This time slot is already booked" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create appointment
|
||||
const appointment = await prisma.appointment.create({
|
||||
data: {
|
||||
patientId,
|
||||
dentistId,
|
||||
serviceId,
|
||||
date: new Date(date),
|
||||
timeSlot,
|
||||
notes: notes || null,
|
||||
status: "pending",
|
||||
},
|
||||
include: {
|
||||
patient: true,
|
||||
dentist: true,
|
||||
service: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create notification for patient
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: patientId,
|
||||
title: "Appointment Booked",
|
||||
message: `Your appointment for ${appointment.service.name} has been booked for ${new Date(date).toLocaleDateString()} at ${timeSlot}`,
|
||||
type: "email",
|
||||
},
|
||||
});
|
||||
|
||||
// Create notification for dentist
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: dentistId,
|
||||
title: "New Appointment",
|
||||
message: `New appointment request from ${appointment.patient.name} for ${new Date(date).toLocaleDateString()} at ${timeSlot}`,
|
||||
type: "email",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(appointment, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error creating appointment:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create appointment" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const userId = searchParams.get("userId");
|
||||
const role = searchParams.get("role");
|
||||
|
||||
let appointments;
|
||||
|
||||
if (role === "patient") {
|
||||
appointments = await safeFindManyAppointments({
|
||||
take: 100, // Limit to 100 most recent appointments
|
||||
where: {
|
||||
patientId: userId || session.user.id,
|
||||
},
|
||||
include: {
|
||||
dentist: true,
|
||||
service: true,
|
||||
payment: true,
|
||||
},
|
||||
orderBy: {
|
||||
date: "desc",
|
||||
},
|
||||
});
|
||||
} else if (role === "dentist") {
|
||||
appointments = await safeFindManyAppointments({
|
||||
take: 100, // Limit to 100 most recent appointments
|
||||
where: {
|
||||
dentistId: userId || session.user.id,
|
||||
},
|
||||
include: {
|
||||
patient: true,
|
||||
service: true,
|
||||
payment: true,
|
||||
},
|
||||
orderBy: {
|
||||
date: "desc",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Admin - get all appointments with pagination limit
|
||||
// Use safe find to filter out orphaned appointments
|
||||
appointments = await safeFindManyAppointments({
|
||||
take: 100, // Limit to 100 most recent appointments
|
||||
include: {
|
||||
patient: true,
|
||||
dentist: true,
|
||||
service: true,
|
||||
payment: true,
|
||||
},
|
||||
orderBy: {
|
||||
date: "desc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(appointments);
|
||||
} catch (error) {
|
||||
console.error("Error fetching appointments:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch appointments" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
4
app/api/auth/[...all]/route.ts
Normal file
4
app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth-session/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth.handler);
|
||||
33
app/api/auth/resend-verification/route.ts
Normal file
33
app/api/auth/resend-verification/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth-session/auth";
|
||||
|
||||
/**
|
||||
* POST /api/auth/resend-verification
|
||||
* Resends the email verification link to the user
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { email } = body;
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Use Better Auth's sendVerificationEmail method
|
||||
await auth.api.sendVerificationEmail({
|
||||
body: { email },
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Verification email sent successfully" },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to resend verification email:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to send verification email" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/api/auth/session/route.ts
Normal file
50
app/api/auth/session/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { auth } from "@/lib/auth-session/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "No session found" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Fetch the full user object from database to get the role
|
||||
// This is necessary because session cache doesn't include additional fields
|
||||
if (session.user) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (dbUser) {
|
||||
// Merge the role from database into the session user object
|
||||
session.user = {
|
||||
...session.user,
|
||||
role: dbUser.role || "patient", // Default to patient if no role set
|
||||
};
|
||||
} else {
|
||||
// Fallback if user not found in database
|
||||
session.user.role = "patient";
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(session);
|
||||
} catch (error) {
|
||||
console.error("Session fetch error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
78
app/api/debug-session/route.ts
Normal file
78
app/api/debug-session/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth-session/auth";
|
||||
|
||||
/**
|
||||
* Debug endpoint to check session and cookie status
|
||||
* Access this at: /api/debug-session
|
||||
*
|
||||
* REMOVE THIS FILE AFTER DEBUGGING
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get all cookies
|
||||
const allCookies = request.cookies.getAll();
|
||||
const sessionToken = request.cookies.get("better-auth.session_token");
|
||||
|
||||
// Try to get session from Better Auth
|
||||
let session = null;
|
||||
let sessionError = null;
|
||||
try {
|
||||
session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
// Get request details
|
||||
const info = {
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: {
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
hasAuthUrl: !!process.env.BETTER_AUTH_URL,
|
||||
authUrl: process.env.BETTER_AUTH_URL,
|
||||
hasAppUrl: !!process.env.NEXT_PUBLIC_APP_URL,
|
||||
appUrl: process.env.NEXT_PUBLIC_APP_URL,
|
||||
},
|
||||
request: {
|
||||
url: request.url,
|
||||
origin: request.headers.get("origin"),
|
||||
referer: request.headers.get("referer"),
|
||||
host: request.headers.get("host"),
|
||||
protocol: request.headers.get("x-forwarded-proto") || "unknown",
|
||||
},
|
||||
cookies: {
|
||||
total: allCookies.length,
|
||||
names: allCookies.map((c) => c.name),
|
||||
hasSessionToken: !!sessionToken,
|
||||
sessionTokenValue: sessionToken?.value
|
||||
? `${sessionToken.value.substring(0, 20)}...`
|
||||
: null,
|
||||
},
|
||||
session: session
|
||||
? {
|
||||
userId: session.user?.id,
|
||||
userEmail: session.user?.email,
|
||||
sessionId: session.session?.id,
|
||||
expiresAt: session.session?.expiresAt,
|
||||
}
|
||||
: null,
|
||||
sessionError,
|
||||
};
|
||||
|
||||
return NextResponse.json(info, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/api/send-appointment-reminder/route.tsx
Normal file
49
app/api/send-appointment-reminder/route.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import DentalAppointmentReminder from "@/components/emails/email-remainder";
|
||||
import { Resend } from "resend";
|
||||
import React from "react";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const {
|
||||
patientName,
|
||||
appointmentDate,
|
||||
appointmentTime,
|
||||
doctorName,
|
||||
treatmentType,
|
||||
duration,
|
||||
clinicPhone,
|
||||
clinicEmail,
|
||||
clinicAddress,
|
||||
to,
|
||||
} = body;
|
||||
|
||||
try {
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: `Dental U Care <${process.env.EMAIL_SENDER_ADDRESS || "onboarding@dentalucare.tech"}>`,
|
||||
to: [to],
|
||||
subject: `Dental Appointment Reminder for ${patientName}`,
|
||||
react: (
|
||||
<DentalAppointmentReminder
|
||||
patientName={patientName}
|
||||
appointmentDate={appointmentDate}
|
||||
appointmentTime={appointmentTime}
|
||||
doctorName={doctorName}
|
||||
treatmentType={treatmentType}
|
||||
duration={duration}
|
||||
clinicPhone={clinicPhone}
|
||||
clinicEmail={clinicEmail}
|
||||
clinicAddress={clinicAddress || ""}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return Response.json({ error }, { status: 500 });
|
||||
}
|
||||
return Response.json(data);
|
||||
} catch (error) {
|
||||
return Response.json({ error }, { status: 500 });
|
||||
}
|
||||
}
|
||||
73
app/api/users/[id]/role/route.ts
Normal file
73
app/api/users/[id]/role/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import { auth } from "@/lib/auth-session/auth";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await import("next/headers").then((mod) => mod.headers()),
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admin can change roles
|
||||
if (session.user.role !== "admin") {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden: Admin access required" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { role } = body;
|
||||
|
||||
// Validate role
|
||||
if (!role || !["patient", "dentist", "admin"].includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid role. Must be patient, dentist, or admin" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Prevent changing own role
|
||||
if (user.id === session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "You cannot change your own role" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update user role
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id },
|
||||
data: { role },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: updatedUser,
|
||||
message: `Role changed to ${role} successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error changing user role:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to change user role" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
5
app/docs/privacy-policy/page.tsx
Normal file
5
app/docs/privacy-policy/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import PrivacyPolicy from "@/components/landing/privacy-policy";
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
return <PrivacyPolicy />;
|
||||
}
|
||||
5
app/docs/terms-and-conditions/page.tsx
Normal file
5
app/docs/terms-and-conditions/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import TermsAndConditions from "@/components/landing/terms-and-conditions";
|
||||
|
||||
export default function TermsAndConditionsPage() {
|
||||
return <TermsAndConditions />;
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
49
app/forbidden/forbidden.tsx
Normal file
49
app/forbidden/forbidden.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { getCurrentUser } from "@/lib/auth-session/auth-server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function ForbiddenPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
// If not authenticated, redirect to sign-in
|
||||
if (!user) {
|
||||
redirect("/sign-in");
|
||||
}
|
||||
|
||||
// Determine the appropriate dashboard based on role
|
||||
const getDashboardUrl = () => {
|
||||
switch (user.role) {
|
||||
case "admin":
|
||||
return "/admin";
|
||||
case "dentist":
|
||||
return "/dentist";
|
||||
case "patient":
|
||||
return "/patient";
|
||||
default:
|
||||
return "/profile";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex grow items-center justify-center px-4 text-center">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold">403</h1>
|
||||
<h2 className="text-2xl font-semibold">Access Denied</h2>
|
||||
<p className="text-muted-foreground">
|
||||
You don't have permission to access this page.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Button asChild variant="default">
|
||||
<Link href={getDashboardUrl()}>Go to Dashboard</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/">Go Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
25
app/forbidden/unauthorized.tsx
Normal file
25
app/forbidden/unauthorized.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function UnauthorizedPage() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<main className="flex grow items-center justify-center px-4 text-center">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">401 - Unauthorized</h1>
|
||||
<p className="text-muted-foreground">Please sign in to continue.</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button asChild>
|
||||
<Link href={`/sign-in?redirect=${pathname}`}>Sign in</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
5
app/get-started/page.tsx
Normal file
5
app/get-started/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import GetStartedGuide from "@/components/landing/get-started";
|
||||
|
||||
export default function GetStartedPage() {
|
||||
return <GetStartedGuide />;
|
||||
}
|
||||
235
app/globals.css
Normal file
235
app/globals.css
Normal file
@@ -0,0 +1,235 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(1.0000 0 0);
|
||||
--foreground: oklch(0.1884 0.0128 248.5103);
|
||||
--card: oklch(0.9784 0.0011 197.1387);
|
||||
--card-foreground: oklch(0.1884 0.0128 248.5103);
|
||||
--popover: oklch(1.0000 0 0);
|
||||
--popover-foreground: oklch(0.1884 0.0128 248.5103);
|
||||
--primary: oklch(0.6723 0.1606 244.9955);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--secondary: oklch(0.1884 0.0128 248.5103);
|
||||
--secondary-foreground: oklch(1.0000 0 0);
|
||||
--muted: oklch(0.9222 0.0013 286.3737);
|
||||
--muted-foreground: oklch(0.1884 0.0128 248.5103);
|
||||
--accent: oklch(0.9392 0.0166 250.8453);
|
||||
--accent-foreground: oklch(0.6723 0.1606 244.9955);
|
||||
--destructive: oklch(0.6188 0.2376 25.7658);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--border: oklch(0.9317 0.0118 231.6594);
|
||||
--input: oklch(0.9809 0.0025 228.7836);
|
||||
--ring: oklch(0.6818 0.1584 243.3540);
|
||||
--chart-1: oklch(0.6723 0.1606 244.9955);
|
||||
--chart-2: oklch(0.6907 0.1554 160.3454);
|
||||
--chart-3: oklch(0.8214 0.1600 82.5337);
|
||||
--chart-4: oklch(0.7064 0.1822 151.7125);
|
||||
--chart-5: oklch(0.5919 0.2186 10.5826);
|
||||
--sidebar: oklch(0.9784 0.0011 197.1387);
|
||||
--sidebar-foreground: oklch(0.1884 0.0128 248.5103);
|
||||
--sidebar-primary: oklch(0.6723 0.1606 244.9955);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-accent: oklch(0.9392 0.0166 250.8453);
|
||||
--sidebar-accent-foreground: oklch(0.6723 0.1606 244.9955);
|
||||
--sidebar-border: oklch(0.9271 0.0101 238.5177);
|
||||
--sidebar-ring: oklch(0.6818 0.1584 243.3540);
|
||||
--font-sans: Open Sans, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Menlo, monospace;
|
||||
--radius: 1.3rem;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 2px;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-opacity: 0;
|
||||
--shadow-color: rgba(29,161,242,0.15);
|
||||
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--tracking-normal: 0em;
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0 0 0);
|
||||
--foreground: oklch(0.9328 0.0025 228.7857);
|
||||
--card: oklch(0.2097 0.0080 274.5332);
|
||||
--card-foreground: oklch(0.8853 0 0);
|
||||
--popover: oklch(0 0 0);
|
||||
--popover-foreground: oklch(0.9328 0.0025 228.7857);
|
||||
--primary: oklch(0.6692 0.1607 245.0110);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--secondary: oklch(0.9622 0.0035 219.5331);
|
||||
--secondary-foreground: oklch(0.1884 0.0128 248.5103);
|
||||
--muted: oklch(0.2090 0 0);
|
||||
--muted-foreground: oklch(0.5637 0.0078 247.9662);
|
||||
--accent: oklch(0.1928 0.0331 242.5459);
|
||||
--accent-foreground: oklch(0.6692 0.1607 245.0110);
|
||||
--destructive: oklch(0.6188 0.2376 25.7658);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--border: oklch(0.2674 0.0047 248.0045);
|
||||
--input: oklch(0.3020 0.0288 244.8244);
|
||||
--ring: oklch(0.6818 0.1584 243.3540);
|
||||
--chart-1: oklch(0.6723 0.1606 244.9955);
|
||||
--chart-2: oklch(0.6907 0.1554 160.3454);
|
||||
--chart-3: oklch(0.8214 0.1600 82.5337);
|
||||
--chart-4: oklch(0.7064 0.1822 151.7125);
|
||||
--chart-5: oklch(0.5919 0.2186 10.5826);
|
||||
--sidebar: oklch(0.2097 0.0080 274.5332);
|
||||
--sidebar-foreground: oklch(0.8853 0 0);
|
||||
--sidebar-primary: oklch(0.6818 0.1584 243.3540);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-accent: oklch(0.1928 0.0331 242.5459);
|
||||
--sidebar-accent-foreground: oklch(0.6692 0.1607 245.0110);
|
||||
--sidebar-border: oklch(0.3795 0.0220 240.5943);
|
||||
--sidebar-ring: oklch(0.6818 0.1584 243.3540);
|
||||
--font-sans: Open Sans, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Menlo, monospace;
|
||||
--radius: 1.3rem;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 2px;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-opacity: 0;
|
||||
--shadow-color: rgba(29,161,242,0.25);
|
||||
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-serif: var(--font-serif);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
--animate-marquee: marquee var(--duration) infinite linear;
|
||||
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
|
||||
--animate-aurora: aurora 60s linear infinite;
|
||||
@keyframes aurora {
|
||||
from {
|
||||
backgroundPosition: 50% 50%, 50% 50%;
|
||||
}
|
||||
to {
|
||||
backgroundPosition: 350% 50%, 350% 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-vertical {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: translateY(calc(-100% - var(--gap)));
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--foreground);
|
||||
border-radius: 5px;
|
||||
}
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--foreground) transparent;
|
||||
}
|
||||
}
|
||||
@utility container {
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.5rem;
|
||||
@media (width >= --theme(--breakpoint-sm)) {
|
||||
max-width: none;
|
||||
}
|
||||
@media (width >= 1440px) {
|
||||
padding-inline: 2rem;
|
||||
max-width: 1440px;
|
||||
}
|
||||
}
|
||||
|
||||
/** Smooth scroll **/
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
42
app/layout.tsx
Normal file
42
app/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/provider/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dental U Care",
|
||||
description: "Your one-stop solution for dental care",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head />
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<Toaster position="top-right" />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
56
app/page.tsx
Normal file
56
app/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Contact } from "@/components/landing/contact";
|
||||
import { Footer } from "@/components/landing/footer";
|
||||
import { Hero } from "@/components/landing/hero";
|
||||
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
|
||||
import { Pricing } from "@/components/landing/pricing";
|
||||
import { Features } from "@/components/landing/features";
|
||||
import { Team } from "@/components/landing/team";
|
||||
import { About } from "@/components/landing/about";
|
||||
import { Services } from "@/components/landing/services";
|
||||
// import { AuroraBackground } from "@/components/ui/shadcn-io/aurora-background";
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className=" py-4 max-w-7xl mx-auto relative z-10 justify-center px-4 sm:px-6 lg:px-8">
|
||||
<NavbarWrapper />
|
||||
<section id="home" className="py-4 my-4">
|
||||
<Hero />
|
||||
</section>
|
||||
<section id="about">
|
||||
<About />
|
||||
</section>
|
||||
<section
|
||||
id="team"
|
||||
className="rounded-2xl shadow-[0_20px_60px_-15px_rgba(251,191,36,0.5)] dark:shadow-[0_20px_60px_-15px_rgba(251,191,36,0.3)] p-8 my-8"
|
||||
>
|
||||
<Team />
|
||||
</section>
|
||||
<section
|
||||
id="features"
|
||||
className="rounded-2xl shadow-[0_20px_60px_-15px_rgba(249,115,22,0.5)] dark:shadow-[0_20px_60px_-15px_rgba(249,115,22,0.3)] p-8 my-8"
|
||||
>
|
||||
<Features />
|
||||
</section>
|
||||
<section
|
||||
id="services"
|
||||
className="rounded-2xl shadow-[0_20px_60px_-15px_rgba(59,130,246,0.5)] dark:shadow-[0_20px_60px_-15px_rgba(59,130,246,0.3)] p-8 my-8"
|
||||
>
|
||||
<Services />
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="pricing"
|
||||
className="rounded-2xl shadow-[0_20px_60px_-15px_rgba(168,85,247,0.5)] dark:shadow-[0_20px_60px_-15px_rgba(168,85,247,0.3)] p-8 my-8"
|
||||
>
|
||||
<Pricing />
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="contact"
|
||||
className="rounded-2xl p-8 my-8 shadow-[0_18px_30px_rgba(236,72,153,0.18)] dark:shadow-[0_18px_30px_rgba(236,72,153,0.12)]"
|
||||
>
|
||||
<Contact />
|
||||
</section>
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
18
app/patient-resources/page.tsx
Normal file
18
app/patient-resources/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Navbar } from "@/components/landing/navbar";
|
||||
|
||||
export default function PatientResourcesPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="min-h-screen bg-background flex flex-col items-center justify-center">
|
||||
<h1 className="text-4xl font-bold mb-6">Patient Resources</h1>
|
||||
<p className="text-lg text-muted-foreground mb-8 max-w-xl text-center">
|
||||
Find new patient forms, insurance information, and financing options to help you prepare for your visit and manage your dental care.
|
||||
</p>
|
||||
{/* Add more resource links or components here as needed */}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
app/services/cosmetic-dentistry/page.tsx
Normal file
11
app/services/cosmetic-dentistry/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { CosmeticDentistry } from "@/components/services/CosmeticDentistry";
|
||||
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
|
||||
|
||||
export default function CosmeticDentistryPage() {
|
||||
return (
|
||||
<main className="py-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<NavbarWrapper />
|
||||
<CosmeticDentistry />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
11
app/services/emergency-care/page.tsx
Normal file
11
app/services/emergency-care/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { EmergencyCare } from "@/components/services/EmergencyCare";
|
||||
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
|
||||
|
||||
export default function EmergencyCarePage() {
|
||||
return (
|
||||
<main className="py-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<NavbarWrapper />
|
||||
<EmergencyCare />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
11
app/services/orthodontics/page.tsx
Normal file
11
app/services/orthodontics/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Orthodontics } from "@/components/services/Orthodontics";
|
||||
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
|
||||
|
||||
export default function OrthodonticsPage() {
|
||||
return (
|
||||
<main className="py-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<NavbarWrapper />
|
||||
<Orthodontics />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
11
app/services/pediatric-dentistry/page.tsx
Normal file
11
app/services/pediatric-dentistry/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { PediatricDentistry } from "@/components/services/PediatricDentistry";
|
||||
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
|
||||
|
||||
export default function PediatricDentistryPage() {
|
||||
return (
|
||||
<main className="py-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<NavbarWrapper />
|
||||
<PediatricDentistry />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
11
app/services/preventive-care/page.tsx
Normal file
11
app/services/preventive-care/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import {PreventiveCare} from "@/components/services/PreventiveCare";
|
||||
import { NavbarWrapper } from "@/components/landing/navbar-wrapper";
|
||||
|
||||
export default function PreventiveCarePage() {
|
||||
return (
|
||||
<main className="py-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<NavbarWrapper />
|
||||
<PreventiveCare />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
33
components.json
Normal file
33
components.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@acme": "https://registry.acme.com/{name}.json",
|
||||
"@tailark": "https://tailark.com/r/{name}.json",
|
||||
"@shadcnblocks": {
|
||||
"url": "https://shadcnblocks.com/r/{name}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}"
|
||||
}
|
||||
},
|
||||
"@shadcnio": "https://www.shadcn.io/r/{name}.json",
|
||||
"@reui": "https://reui.io/r/{name}.json"
|
||||
}
|
||||
}
|
||||
883
components/admin/appointments-table.tsx
Normal file
883
components/admin/appointments-table.tsx
Normal file
@@ -0,0 +1,883 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconDotsVertical,
|
||||
IconLayoutColumns,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import { Calendar, Clock, User, Mail } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
confirmAppointments,
|
||||
cancelAppointments,
|
||||
completeAppointments,
|
||||
deleteAppointments,
|
||||
deleteAppointment,
|
||||
} from "@/lib/actions/admin-actions";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type Appointment = {
|
||||
id: string;
|
||||
date: Date;
|
||||
timeSlot: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
patient: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
dentist: {
|
||||
name: string;
|
||||
};
|
||||
service: {
|
||||
name: string;
|
||||
price: string;
|
||||
};
|
||||
payment: {
|
||||
status: string;
|
||||
amount: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
pending: "secondary",
|
||||
confirmed: "default",
|
||||
cancelled: "destructive",
|
||||
completed: "outline",
|
||||
rescheduled: "secondary",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "default"} className="text-xs">
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getPaymentBadge = (status: string) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
paid: "default",
|
||||
pending: "secondary",
|
||||
failed: "destructive",
|
||||
refunded: "outline",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "default"} className="text-xs">
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Appointment>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "patientName",
|
||||
accessorFn: (row) => row.patient.name,
|
||||
header: "Patient",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.patient.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.original.patient.email}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "dentistName",
|
||||
accessorFn: (row) => row.dentist.name,
|
||||
header: "Dentist",
|
||||
cell: ({ row }) => <span>Dr. {row.original.dentist.name}</span>,
|
||||
},
|
||||
{
|
||||
id: "serviceName",
|
||||
accessorFn: (row) => row.service.name,
|
||||
header: "Service",
|
||||
cell: ({ row }) => row.original.service.name,
|
||||
},
|
||||
{
|
||||
accessorKey: "date",
|
||||
header: "Date & Time",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p>{new Date(row.original.date).toLocaleDateString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.original.timeSlot}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => getStatusBadge(row.original.status),
|
||||
},
|
||||
{
|
||||
id: "paymentStatus",
|
||||
accessorFn: (row) => row.payment?.status || "none",
|
||||
header: "Payment",
|
||||
cell: ({ row }) =>
|
||||
row.original.payment ? getPaymentBadge(row.original.payment.status) : "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: () => <div className="text-right">Amount</div>,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.payment) {
|
||||
const amount = row.original.payment.amount;
|
||||
return <div className="text-right">₱{amount.toFixed(2)}</div>;
|
||||
}
|
||||
// service.price is a string (e.g., "₱500 – ₱1,500" or "₱1,500")
|
||||
const price = row.original.service.price;
|
||||
return <div className="text-right">{price}</div>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
type AdminAppointmentsTableProps = {
|
||||
appointments: Appointment[];
|
||||
};
|
||||
|
||||
export function AdminAppointmentsTable({
|
||||
appointments,
|
||||
}: AdminAppointmentsTableProps) {
|
||||
const router = useRouter();
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [selectedAppointment, setSelectedAppointment] =
|
||||
React.useState<Appointment | null>(null);
|
||||
|
||||
const formatPrice = (price: number | string): string => {
|
||||
if (typeof price === "string") {
|
||||
return price;
|
||||
}
|
||||
if (isNaN(price)) {
|
||||
return "Contact for pricing";
|
||||
}
|
||||
return `₱${price.toLocaleString()}`;
|
||||
};
|
||||
|
||||
const handleBulkAction = async (
|
||||
action: (ids: string[]) => Promise<{ success: boolean; message: string }>,
|
||||
actionName: string
|
||||
) => {
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||
const ids = selectedRows.map((row) => row.original.id);
|
||||
|
||||
if (ids.length === 0) {
|
||||
toast.error("No appointments selected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await action(ids);
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setRowSelection({});
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${actionName}`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSingleAction = async (
|
||||
action: () => Promise<{ success: boolean; message: string }>,
|
||||
actionName: string
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await action();
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${actionName}`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const actionsColumn: ColumnDef<Appointment> = {
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSelectedAppointment(row.original)}
|
||||
>
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Edit feature coming soon")}
|
||||
>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Reschedule feature coming soon")}
|
||||
>
|
||||
Reschedule
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleSingleAction(
|
||||
() => cancelAppointments([row.original.id]),
|
||||
"cancel appointment"
|
||||
)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
handleSingleAction(
|
||||
() => deleteAppointment(row.original.id),
|
||||
"delete appointment"
|
||||
)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
};
|
||||
|
||||
const columnsWithActions = [...columns, actionsColumn];
|
||||
|
||||
const table = useReactTable({
|
||||
data: appointments,
|
||||
columns: columnsWithActions,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search appointments..."
|
||||
value={
|
||||
(table.getColumn("patientName")?.getFilterValue() as string) ?? ""
|
||||
}
|
||||
onChange={(event) =>
|
||||
table.getColumn("patientName")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span className="hidden lg:inline">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" &&
|
||||
column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Toolbar */}
|
||||
{table.getFilteredSelectedRowModel().rows.length > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
|
||||
<span className="text-sm font-medium">
|
||||
{table.getFilteredSelectedRowModel().rows.length} selected
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() =>
|
||||
handleBulkAction(confirmAppointments, "confirm appointments")
|
||||
}
|
||||
>
|
||||
Confirm Selected
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() => toast.info("Reschedule feature coming soon")}
|
||||
>
|
||||
Reschedule Selected
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() => toast.info("Send reminders feature coming soon")}
|
||||
>
|
||||
Send Reminders
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={isLoading}>
|
||||
More Actions
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleBulkAction(
|
||||
completeAppointments,
|
||||
"complete appointments"
|
||||
)
|
||||
}
|
||||
>
|
||||
Mark as Completed
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Export feature coming soon")}
|
||||
>
|
||||
Export Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleBulkAction(cancelAppointments, "cancel appointments")
|
||||
}
|
||||
>
|
||||
Cancel Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
handleBulkAction(deleteAppointments, "delete appointments")
|
||||
}
|
||||
>
|
||||
Delete Selected
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No appointments found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
<SelectValue
|
||||
placeholder={table.getState().pagination.pageSize}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Appointment Details Dialog */}
|
||||
<Dialog
|
||||
open={!!selectedAppointment}
|
||||
onOpenChange={() => setSelectedAppointment(null)}
|
||||
>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl">
|
||||
Appointment Details
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Booking ID: {selectedAppointment?.id}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{selectedAppointment &&
|
||||
getStatusBadge(selectedAppointment.status)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedAppointment && (
|
||||
<div className="space-y-6">
|
||||
{/* Patient Information */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Patient Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Patient Name
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{selectedAppointment.patient.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Mail className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Email</p>
|
||||
<p className="font-medium text-sm">
|
||||
{selectedAppointment.patient.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Information */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Service Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Service</p>
|
||||
<p className="font-medium">
|
||||
{selectedAppointment.service.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Price</p>
|
||||
<p className="font-medium">
|
||||
{formatPrice(selectedAppointment.service.price)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Appointment Schedule */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Appointment Schedule
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Date</p>
|
||||
<p className="font-medium">
|
||||
{new Date(selectedAppointment.date).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Time</p>
|
||||
<p className="font-medium">
|
||||
{selectedAppointment.timeSlot}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dentist Information */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Assigned Dentist
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Dentist</p>
|
||||
<p className="font-medium">
|
||||
Dr. {selectedAppointment.dentist.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Information */}
|
||||
{selectedAppointment.payment && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Payment Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Payment Status
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
{getPaymentBadge(selectedAppointment.payment.status)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Amount</p>
|
||||
<p className="font-medium">
|
||||
₱{selectedAppointment.payment.amount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{selectedAppointment.notes && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Special Requests / Notes
|
||||
</h3>
|
||||
<p className="text-sm bg-muted p-3 rounded-lg">
|
||||
{selectedAppointment.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Action Buttons */}
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
{selectedAppointment.status === "pending" && (
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
const id = selectedAppointment.id;
|
||||
setSelectedAppointment(null);
|
||||
handleSingleAction(
|
||||
() => confirmAppointments([id]),
|
||||
"confirm appointment"
|
||||
);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Confirm Appointment
|
||||
</Button>
|
||||
)}
|
||||
{(selectedAppointment.status === "pending" ||
|
||||
selectedAppointment.status === "confirmed") && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setSelectedAppointment(null);
|
||||
toast.info("Reschedule feature coming soon");
|
||||
}}
|
||||
>
|
||||
Reschedule
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
const id = selectedAppointment.id;
|
||||
setSelectedAppointment(null);
|
||||
handleSingleAction(
|
||||
() => cancelAppointments([id]),
|
||||
"cancel appointment"
|
||||
);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel Appointment
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{selectedAppointment.status === "confirmed" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
const id = selectedAppointment.id;
|
||||
setSelectedAppointment(null);
|
||||
handleSingleAction(
|
||||
() => completeAppointments([id]),
|
||||
"mark as completed"
|
||||
);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Mark as Completed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
807
components/admin/dentists-table.tsx
Normal file
807
components/admin/dentists-table.tsx
Normal file
@@ -0,0 +1,807 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconDotsVertical,
|
||||
IconLayoutColumns,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import { User, Mail, Phone, Calendar, Award, Briefcase } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
updateDentistAvailability,
|
||||
deleteDentist,
|
||||
} from "@/lib/actions/admin-actions";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type Dentist = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
specialization: string | null;
|
||||
qualifications: string | null;
|
||||
experience: string | null;
|
||||
isAvailable: boolean;
|
||||
createdAt: Date;
|
||||
appointmentsAsDentist: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Dentist>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">Dr. {row.original.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.original.email}</p>
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "specialization",
|
||||
header: "Specialization",
|
||||
cell: ({ row }) => row.original.specialization || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "experience",
|
||||
header: "Experience",
|
||||
cell: ({ row }) => row.original.experience || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "appointments",
|
||||
header: "Appointments",
|
||||
cell: ({ row }) => {
|
||||
const appointmentCount = row.original.appointmentsAsDentist.length;
|
||||
const completedCount = row.original.appointmentsAsDentist.filter(
|
||||
(apt) => apt.status === "completed"
|
||||
).length;
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<p>{appointmentCount} total</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{completedCount} completed
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "isAvailable",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.isAvailable ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{row.original.isAvailable ? "Available" : "Unavailable"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Joined",
|
||||
cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: () => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem>View Details</DropdownMenuItem>
|
||||
<DropdownMenuItem>Edit Profile</DropdownMenuItem>
|
||||
<DropdownMenuItem>View Schedule</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">Deactivate</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
type AdminDentistsTableProps = {
|
||||
dentists: Dentist[];
|
||||
};
|
||||
|
||||
export function AdminDentistsTable({ dentists }: AdminDentistsTableProps) {
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [selectedDentist, setSelectedDentist] = React.useState<Dentist | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const handleBulkAvailability = async (isAvailable: boolean) => {
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||
const ids = selectedRows.map((row) => row.original.id);
|
||||
|
||||
if (ids.length === 0) {
|
||||
toast.error("No dentists selected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateDentistAvailability(ids, isAvailable);
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setRowSelection({});
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to update availability`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSingleAction = async (
|
||||
action: () => Promise<{ success: boolean; message: string }>,
|
||||
actionName: string
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await action();
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${actionName}`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const actionsColumn: ColumnDef<Dentist> = {
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem onClick={() => setSelectedDentist(row.original)}>
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Edit feature coming soon")}
|
||||
>
|
||||
Edit Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Schedule feature coming soon")}
|
||||
>
|
||||
View Schedule
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleSingleAction(
|
||||
() =>
|
||||
updateDentistAvailability(
|
||||
[row.original.id],
|
||||
!row.original.isAvailable
|
||||
),
|
||||
row.original.isAvailable ? "set unavailable" : "set available"
|
||||
)
|
||||
}
|
||||
>
|
||||
{row.original.isAvailable ? "Set Unavailable" : "Set Available"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (confirm("Are you sure you want to delete this dentist?")) {
|
||||
handleSingleAction(
|
||||
() => deleteDentist(row.original.id),
|
||||
"delete dentist"
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
};
|
||||
|
||||
const columnsWithActions = [...columns.slice(0, -1), actionsColumn];
|
||||
|
||||
const table = useReactTable({
|
||||
data: dentists,
|
||||
columns: columnsWithActions,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search dentists..."
|
||||
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn("name")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span className="hidden lg:inline">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" &&
|
||||
column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Toolbar */}
|
||||
{table.getFilteredSelectedRowModel().rows.length > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
|
||||
<span className="text-sm font-medium">
|
||||
{table.getFilteredSelectedRowModel().rows.length} selected
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() => handleBulkAvailability(true)}
|
||||
>
|
||||
Set Available
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() => handleBulkAvailability(false)}
|
||||
>
|
||||
Set Unavailable
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={isLoading}>
|
||||
More Actions
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Send notification feature coming soon")
|
||||
}
|
||||
>
|
||||
Send Notification
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Export feature coming soon")}
|
||||
>
|
||||
Export Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Schedule feature coming soon")}
|
||||
>
|
||||
View Schedules
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to deactivate these dentists?"
|
||||
)
|
||||
) {
|
||||
handleBulkAvailability(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Deactivate Selected
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No dentists found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
<SelectValue
|
||||
placeholder={table.getState().pagination.pageSize}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dentist Details Dialog */}
|
||||
<Dialog
|
||||
open={!!selectedDentist}
|
||||
onOpenChange={() => setSelectedDentist(null)}
|
||||
>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl">Dentist Details</DialogTitle>
|
||||
<DialogDescription>ID: {selectedDentist?.id}</DialogDescription>
|
||||
</div>
|
||||
{selectedDentist && (
|
||||
<Badge
|
||||
variant={
|
||||
selectedDentist.isAvailable ? "default" : "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{selectedDentist.isAvailable ? "Available" : "Unavailable"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedDentist && (
|
||||
<div className="space-y-6">
|
||||
{/* Personal Information */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Personal Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Full Name</p>
|
||||
<p className="font-medium">Dr. {selectedDentist.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Mail className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Email</p>
|
||||
<p className="font-medium text-sm">
|
||||
{selectedDentist.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedDentist.phone && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Phone className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Phone</p>
|
||||
<p className="font-medium">{selectedDentist.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Joined</p>
|
||||
<p className="font-medium">
|
||||
{new Date(selectedDentist.createdAt).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Professional Information */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Professional Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedDentist.specialization && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Award className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Specialization
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{selectedDentist.specialization}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedDentist.experience && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Briefcase className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Experience
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{selectedDentist.experience}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedDentist.qualifications && (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-muted-foreground mb-1">
|
||||
Qualifications
|
||||
</p>
|
||||
<p className="text-sm bg-muted p-3 rounded-lg">
|
||||
{selectedDentist.qualifications}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Appointment Statistics */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Appointment Statistics
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<p className="text-2xl font-bold">
|
||||
{selectedDentist.appointmentsAsDentist.length}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Total Appointments
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<p className="text-2xl font-bold">
|
||||
{
|
||||
selectedDentist.appointmentsAsDentist.filter(
|
||||
(apt) => apt.status === "completed"
|
||||
).length
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Completed</p>
|
||||
</div>
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<p className="text-2xl font-bold">
|
||||
{
|
||||
selectedDentist.appointmentsAsDentist.filter(
|
||||
(apt) =>
|
||||
apt.status === "pending" ||
|
||||
apt.status === "confirmed"
|
||||
).length
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Upcoming</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setSelectedDentist(null);
|
||||
toast.info("Edit feature coming soon");
|
||||
}}
|
||||
>
|
||||
Edit Profile
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setSelectedDentist(null);
|
||||
toast.info("Schedule feature coming soon");
|
||||
}}
|
||||
>
|
||||
View Schedule
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedDentist.isAvailable ? "outline" : "default"}
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
const id = selectedDentist.id;
|
||||
const newStatus = !selectedDentist.isAvailable;
|
||||
setSelectedDentist(null);
|
||||
handleSingleAction(
|
||||
() => updateDentistAvailability([id], newStatus),
|
||||
newStatus ? "set available" : "set unavailable"
|
||||
);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{selectedDentist.isAvailable
|
||||
? "Set Unavailable"
|
||||
: "Set Available"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
437
components/admin/patients-table.tsx
Normal file
437
components/admin/patients-table.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconDotsVertical,
|
||||
IconLayoutColumns,
|
||||
IconSearch,
|
||||
IconMail,
|
||||
IconPhone,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
|
||||
type Patient = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
dateOfBirth: Date | null;
|
||||
medicalHistory: string | null;
|
||||
createdAt: Date;
|
||||
appointmentsAsPatient: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
}>;
|
||||
payments: Array<{
|
||||
id: string;
|
||||
amount: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Patient>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "contact",
|
||||
header: "Contact",
|
||||
cell: ({ row }) => (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<IconMail className="size-3" />
|
||||
<span className="text-xs">{row.original.email}</span>
|
||||
</div>
|
||||
{row.original.phone && (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<IconPhone className="size-3" />
|
||||
<span className="text-xs">{row.original.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "dateOfBirth",
|
||||
header: "Date of Birth",
|
||||
cell: ({ row }) =>
|
||||
row.original.dateOfBirth
|
||||
? new Date(row.original.dateOfBirth).toLocaleDateString()
|
||||
: "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "appointments",
|
||||
header: "Appointments",
|
||||
cell: ({ row }) => row.original.appointmentsAsPatient.length,
|
||||
},
|
||||
{
|
||||
accessorKey: "totalSpent",
|
||||
header: () => <div className="text-right">Total Spent</div>,
|
||||
cell: ({ row }) => {
|
||||
const totalSpent = row.original.payments.reduce(
|
||||
(sum, payment) => sum + payment.amount,
|
||||
0
|
||||
);
|
||||
return <div className="text-right">₱{totalSpent.toFixed(2)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Joined",
|
||||
cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: () => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem>View Details</DropdownMenuItem>
|
||||
<DropdownMenuItem>Edit Profile</DropdownMenuItem>
|
||||
<DropdownMenuItem>View History</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
type AdminPatientsTableProps = {
|
||||
patients: Patient[];
|
||||
};
|
||||
|
||||
export function AdminPatientsTable({ patients }: AdminPatientsTableProps) {
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data: patients,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search patients..."
|
||||
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn("name")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span className="hidden lg:inline">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" &&
|
||||
column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Toolbar */}
|
||||
{table.getFilteredSelectedRowModel().rows.length > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
|
||||
<span className="text-sm font-medium">
|
||||
{table.getFilteredSelectedRowModel().rows.length} selected
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
Send Email
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Send SMS
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
More Actions
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>Export Selected</DropdownMenuItem>
|
||||
<DropdownMenuItem>Add to Group</DropdownMenuItem>
|
||||
<DropdownMenuItem>Send Appointment Reminder</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
Delete Selected
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No patients found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
<SelectValue
|
||||
placeholder={table.getState().pagination.pageSize}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
572
components/admin/services-table.tsx
Normal file
572
components/admin/services-table.tsx
Normal file
@@ -0,0 +1,572 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconDotsVertical,
|
||||
IconLayoutColumns,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
updateServiceStatus,
|
||||
deleteServices,
|
||||
deleteService,
|
||||
duplicateService,
|
||||
} from "@/lib/actions/admin-actions";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
type Service = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
duration: number;
|
||||
price: string;
|
||||
category: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
appointments: Array<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Service>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Service Name",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.name}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">
|
||||
{row.original.description}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "category",
|
||||
header: "Category",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{row.original.category}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "duration",
|
||||
header: "Duration",
|
||||
cell: ({ row }) => `${row.original.duration} mins`,
|
||||
},
|
||||
{
|
||||
accessorKey: "price",
|
||||
header: () => <div className="text-right">Price</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right font-medium">
|
||||
{row.original.price || "N/A"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "bookings",
|
||||
header: "Bookings",
|
||||
cell: ({ row }) => row.original.appointments.length,
|
||||
},
|
||||
{
|
||||
accessorKey: "isActive",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.isActive ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{row.original.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
type AdminServicesTableProps = {
|
||||
services: Service[];
|
||||
};
|
||||
|
||||
export function AdminServicesTable({ services }: AdminServicesTableProps) {
|
||||
const router = useRouter();
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
const handleServiceAction = async (
|
||||
action: () => Promise<{ success: boolean; message: string }>,
|
||||
actionName: string
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await action();
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${actionName}`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAction = async (
|
||||
action: (ids: string[]) => Promise<{ success: boolean; message: string }>,
|
||||
actionName: string
|
||||
) => {
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||
const ids = selectedRows.map((row) => row.original.id);
|
||||
|
||||
if (ids.length === 0) {
|
||||
toast.error("No services selected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await action(ids);
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setRowSelection({});
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${actionName}`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const actionsColumn: ColumnDef<Service> = {
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Edit feature coming soon")}
|
||||
>
|
||||
Edit Service
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleServiceAction(
|
||||
() => duplicateService(row.original.id),
|
||||
"duplicate service"
|
||||
)
|
||||
}
|
||||
>
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleServiceAction(
|
||||
() =>
|
||||
updateServiceStatus(
|
||||
[row.original.id],
|
||||
!row.original.isActive
|
||||
),
|
||||
"toggle service status"
|
||||
)
|
||||
}
|
||||
>
|
||||
{row.original.isActive ? "Deactivate" : "Activate"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
handleServiceAction(
|
||||
() => deleteService(row.original.id),
|
||||
"delete service"
|
||||
)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
};
|
||||
|
||||
const columnsWithActions = [...columns, actionsColumn];
|
||||
|
||||
const table = useReactTable({
|
||||
data: services,
|
||||
columns: columnsWithActions,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search services..."
|
||||
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn("name")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span className="hidden lg:inline">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" &&
|
||||
column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Toolbar */}
|
||||
{table.getFilteredSelectedRowModel().rows.length > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
|
||||
<span className="text-sm font-medium">
|
||||
{table.getFilteredSelectedRowModel().rows.length} selected
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() =>
|
||||
handleBulkAction(
|
||||
(ids) => updateServiceStatus(ids, true),
|
||||
"activate services"
|
||||
)
|
||||
}
|
||||
>
|
||||
Activate Selected
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() =>
|
||||
handleBulkAction(
|
||||
(ids) => updateServiceStatus(ids, false),
|
||||
"deactivate services"
|
||||
)
|
||||
}
|
||||
>
|
||||
Deactivate Selected
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={isLoading}>
|
||||
More Actions
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Duplicate feature coming soon")}
|
||||
>
|
||||
Duplicate Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Update prices feature coming soon")
|
||||
}
|
||||
>
|
||||
Update Prices
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Export feature coming soon")}
|
||||
>
|
||||
Export Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
handleBulkAction(deleteServices, "delete services")
|
||||
}
|
||||
>
|
||||
Delete Selected
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No services found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
<SelectValue
|
||||
placeholder={table.getState().pagination.pageSize}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
749
components/admin/settings-content.tsx
Normal file
749
components/admin/settings-content.tsx
Normal file
@@ -0,0 +1,749 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
IconBell,
|
||||
IconBriefcase,
|
||||
IconKey,
|
||||
IconShield,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getAdminSettings,
|
||||
updateAdminSettings,
|
||||
} from "@/lib/actions/settings-actions";
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
type AdminSettingsContentProps = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export function AdminSettingsContent({}: AdminSettingsContentProps) {
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
// General Settings State
|
||||
const [clinicName, setClinicName] = React.useState("Dental U-Care");
|
||||
const [clinicEmail, setClinicEmail] = React.useState("info@dentalucare.com");
|
||||
const [clinicPhone, setClinicPhone] = React.useState("+1 (555) 123-4567");
|
||||
const [clinicAddress, setClinicAddress] = React.useState(
|
||||
"123 Medical Plaza, Suite 100"
|
||||
);
|
||||
const [timezone, setTimezone] = React.useState("America/New_York");
|
||||
|
||||
// Appointment Settings State
|
||||
const [appointmentDuration, setAppointmentDuration] = React.useState("60");
|
||||
const [bufferTime, setBufferTime] = React.useState("15");
|
||||
const [maxAdvanceBooking, setMaxAdvanceBooking] = React.useState("90");
|
||||
const [cancellationDeadline, setCancellationDeadline] = React.useState("24");
|
||||
const [autoConfirmAppointments, setAutoConfirmAppointments] =
|
||||
React.useState(false);
|
||||
|
||||
// Notification Settings State
|
||||
const [emailNotifications, setEmailNotifications] = React.useState(true);
|
||||
const [smsNotifications, setSmsNotifications] = React.useState(true);
|
||||
const [appointmentReminders, setAppointmentReminders] = React.useState(true);
|
||||
const [reminderHoursBefore, setReminderHoursBefore] = React.useState("24");
|
||||
const [newBookingNotifications, setNewBookingNotifications] =
|
||||
React.useState(true);
|
||||
const [cancellationNotifications, setCancellationNotifications] =
|
||||
React.useState(true);
|
||||
|
||||
// Payment Settings State
|
||||
const [requirePaymentUpfront, setRequirePaymentUpfront] =
|
||||
React.useState(false);
|
||||
const [allowPartialPayment, setAllowPartialPayment] = React.useState(true);
|
||||
const [depositPercentage, setDepositPercentage] = React.useState("50");
|
||||
const [acceptCash, setAcceptCash] = React.useState(true);
|
||||
const [acceptCard, setAcceptCard] = React.useState(true);
|
||||
const [acceptEWallet, setAcceptEWallet] = React.useState(true);
|
||||
|
||||
// Security Settings State
|
||||
const [twoFactorAuth, setTwoFactorAuth] = React.useState(false);
|
||||
const [sessionTimeout, setSessionTimeout] = React.useState("60");
|
||||
const [passwordExpiry, setPasswordExpiry] = React.useState("90");
|
||||
const [loginAttempts, setLoginAttempts] = React.useState("5");
|
||||
|
||||
// Load settings on mount
|
||||
React.useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const settings = await getAdminSettings();
|
||||
if (settings) {
|
||||
setClinicName(settings.clinicName);
|
||||
setClinicEmail(settings.clinicEmail);
|
||||
setClinicPhone(settings.clinicPhone);
|
||||
setClinicAddress(settings.clinicAddress);
|
||||
setTimezone(settings.timezone);
|
||||
setAppointmentDuration(settings.appointmentDuration);
|
||||
setBufferTime(settings.bufferTime);
|
||||
setMaxAdvanceBooking(settings.maxAdvanceBooking);
|
||||
setCancellationDeadline(settings.cancellationDeadline);
|
||||
setAutoConfirmAppointments(settings.autoConfirmAppointments);
|
||||
setEmailNotifications(settings.emailNotifications);
|
||||
setSmsNotifications(settings.smsNotifications);
|
||||
setAppointmentReminders(settings.appointmentReminders);
|
||||
setReminderHoursBefore(settings.reminderHoursBefore);
|
||||
setNewBookingNotifications(settings.newBookingNotifications);
|
||||
setCancellationNotifications(settings.cancellationNotifications);
|
||||
setRequirePaymentUpfront(settings.requirePaymentUpfront);
|
||||
setAllowPartialPayment(settings.allowPartialPayment);
|
||||
setDepositPercentage(settings.depositPercentage);
|
||||
setAcceptCash(settings.acceptCash);
|
||||
setAcceptCard(settings.acceptCard);
|
||||
setAcceptEWallet(settings.acceptEWallet);
|
||||
setTwoFactorAuth(settings.twoFactorAuth);
|
||||
setSessionTimeout(settings.sessionTimeout);
|
||||
setPasswordExpiry(settings.passwordExpiry);
|
||||
setLoginAttempts(settings.loginAttempts);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin settings:", error);
|
||||
toast.error("Failed to load settings");
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const handleSaveGeneral = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateAdminSettings({
|
||||
clinicName,
|
||||
clinicEmail,
|
||||
clinicPhone,
|
||||
clinicAddress,
|
||||
timezone,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to save general settings");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAppointments = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateAdminSettings({
|
||||
appointmentDuration,
|
||||
bufferTime,
|
||||
maxAdvanceBooking,
|
||||
cancellationDeadline,
|
||||
autoConfirmAppointments,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to save appointment settings");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveNotifications = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateAdminSettings({
|
||||
emailNotifications,
|
||||
smsNotifications,
|
||||
appointmentReminders,
|
||||
reminderHoursBefore,
|
||||
newBookingNotifications,
|
||||
cancellationNotifications,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to save notification settings");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePayments = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateAdminSettings({
|
||||
requirePaymentUpfront,
|
||||
allowPartialPayment,
|
||||
depositPercentage,
|
||||
acceptCash,
|
||||
acceptCard,
|
||||
acceptEWallet,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to save payment settings");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSecurity = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateAdminSettings({
|
||||
twoFactorAuth,
|
||||
sessionTimeout,
|
||||
passwordExpiry,
|
||||
loginAttempts,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to save security settings");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Admin Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your clinic settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="general" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-5">
|
||||
<TabsTrigger value="general" className="gap-2">
|
||||
<IconBriefcase className="size-4" />
|
||||
<span className="hidden sm:inline">General</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="appointments" className="gap-2">
|
||||
<IconUser className="size-4" />
|
||||
<span className="hidden sm:inline">Appointments</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notifications" className="gap-2">
|
||||
<IconBell className="size-4" />
|
||||
<span className="hidden sm:inline">Notifications</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="payments" className="gap-2">
|
||||
<IconKey className="size-4" />
|
||||
<span className="hidden sm:inline">Payments</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security" className="gap-2">
|
||||
<IconShield className="size-4" />
|
||||
<span className="hidden sm:inline">Security</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* General Settings */}
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Clinic Information</CardTitle>
|
||||
<CardDescription>
|
||||
Update your clinic's basic information and contact details
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clinic-name">Clinic Name</Label>
|
||||
<Input
|
||||
id="clinic-name"
|
||||
value={clinicName}
|
||||
onChange={(e) => setClinicName(e.target.value)}
|
||||
placeholder="Dental U-Care"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clinic-email">Email Address</Label>
|
||||
<Input
|
||||
id="clinic-email"
|
||||
type="email"
|
||||
value={clinicEmail}
|
||||
onChange={(e) => setClinicEmail(e.target.value)}
|
||||
placeholder="info@dentalucare.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clinic-phone">Phone Number</Label>
|
||||
<Input
|
||||
id="clinic-phone"
|
||||
value={clinicPhone}
|
||||
onChange={(e) => setClinicPhone(e.target.value)}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timezone">Timezone</Label>
|
||||
<Select value={timezone} onValueChange={setTimezone}>
|
||||
<SelectTrigger id="timezone">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="America/New_York">
|
||||
Eastern Time (ET)
|
||||
</SelectItem>
|
||||
<SelectItem value="America/Chicago">
|
||||
Central Time (CT)
|
||||
</SelectItem>
|
||||
<SelectItem value="America/Denver">
|
||||
Mountain Time (MT)
|
||||
</SelectItem>
|
||||
<SelectItem value="America/Los_Angeles">
|
||||
Pacific Time (PT)
|
||||
</SelectItem>
|
||||
<SelectItem value="Asia/Manila">
|
||||
Philippine Time (PHT)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clinic-address">Clinic Address</Label>
|
||||
<Textarea
|
||||
id="clinic-address"
|
||||
value={clinicAddress}
|
||||
onChange={(e) => setClinicAddress(e.target.value)}
|
||||
placeholder="123 Medical Plaza, Suite 100"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveGeneral} disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Appointment Settings */}
|
||||
<TabsContent value="appointments" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appointment Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Manage appointment durations, booking limits, and scheduling
|
||||
rules
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="appointment-duration">
|
||||
Default Duration (minutes)
|
||||
</Label>
|
||||
<Select
|
||||
value={appointmentDuration}
|
||||
onValueChange={setAppointmentDuration}
|
||||
>
|
||||
<SelectTrigger id="appointment-duration">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="30">30 minutes</SelectItem>
|
||||
<SelectItem value="45">45 minutes</SelectItem>
|
||||
<SelectItem value="60">60 minutes</SelectItem>
|
||||
<SelectItem value="90">90 minutes</SelectItem>
|
||||
<SelectItem value="120">120 minutes</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="buffer-time">Buffer Time (minutes)</Label>
|
||||
<Select value={bufferTime} onValueChange={setBufferTime}>
|
||||
<SelectTrigger id="buffer-time">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">No buffer</SelectItem>
|
||||
<SelectItem value="10">10 minutes</SelectItem>
|
||||
<SelectItem value="15">15 minutes</SelectItem>
|
||||
<SelectItem value="20">20 minutes</SelectItem>
|
||||
<SelectItem value="30">30 minutes</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-advance">
|
||||
Maximum Advance Booking (days)
|
||||
</Label>
|
||||
<Input
|
||||
id="max-advance"
|
||||
type="number"
|
||||
value={maxAdvanceBooking}
|
||||
onChange={(e) => setMaxAdvanceBooking(e.target.value)}
|
||||
min="1"
|
||||
max="365"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cancellation-deadline">
|
||||
Cancellation Deadline (hours)
|
||||
</Label>
|
||||
<Input
|
||||
id="cancellation-deadline"
|
||||
type="number"
|
||||
value={cancellationDeadline}
|
||||
onChange={(e) => setCancellationDeadline(e.target.value)}
|
||||
min="1"
|
||||
max="72"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Auto-confirm Appointments</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically confirm new bookings without manual review
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoConfirmAppointments}
|
||||
onCheckedChange={setAutoConfirmAppointments}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveAppointments} disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<TabsContent value="notifications" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Preferences</CardTitle>
|
||||
<CardDescription>
|
||||
Configure email, SMS, and reminder notifications
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Email Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive notifications via email
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={emailNotifications}
|
||||
onCheckedChange={setEmailNotifications}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>SMS Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive notifications via SMS
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={smsNotifications}
|
||||
onCheckedChange={setSmsNotifications}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Appointment Reminders</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send reminders to patients before appointments
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={appointmentReminders}
|
||||
onCheckedChange={setAppointmentReminders}
|
||||
/>
|
||||
</div>
|
||||
{appointmentReminders && (
|
||||
<div className="ml-6 space-y-2">
|
||||
<Label htmlFor="reminder-hours">
|
||||
Send reminder (hours before)
|
||||
</Label>
|
||||
<Select
|
||||
value={reminderHoursBefore}
|
||||
onValueChange={setReminderHoursBefore}
|
||||
>
|
||||
<SelectTrigger id="reminder-hours">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">2 hours</SelectItem>
|
||||
<SelectItem value="4">4 hours</SelectItem>
|
||||
<SelectItem value="12">12 hours</SelectItem>
|
||||
<SelectItem value="24">24 hours</SelectItem>
|
||||
<SelectItem value="48">48 hours</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>New Booking Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get notified when new appointments are booked
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={newBookingNotifications}
|
||||
onCheckedChange={setNewBookingNotifications}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Cancellation Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get notified when appointments are cancelled
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={cancellationNotifications}
|
||||
onCheckedChange={setCancellationNotifications}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveNotifications} disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Payment Settings */}
|
||||
<TabsContent value="payments" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Payment Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Manage payment methods and billing preferences
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Require Payment Upfront</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Require full payment when booking
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={requirePaymentUpfront}
|
||||
onCheckedChange={setRequirePaymentUpfront}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Allow Partial Payment</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Allow patients to pay a deposit
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={allowPartialPayment}
|
||||
onCheckedChange={setAllowPartialPayment}
|
||||
/>
|
||||
</div>
|
||||
{allowPartialPayment && (
|
||||
<div className="ml-6 space-y-2">
|
||||
<Label htmlFor="deposit-percentage">
|
||||
Deposit Percentage
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="deposit-percentage"
|
||||
type="number"
|
||||
value={depositPercentage}
|
||||
onChange={(e) => setDepositPercentage(e.target.value)}
|
||||
min="10"
|
||||
max="100"
|
||||
className="pr-8"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<Label>Accepted Payment Methods</Label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="font-normal">Cash</Label>
|
||||
<Switch
|
||||
checked={acceptCash}
|
||||
onCheckedChange={setAcceptCash}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="font-normal">Credit/Debit Card</Label>
|
||||
<Switch
|
||||
checked={acceptCard}
|
||||
onCheckedChange={setAcceptCard}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="font-normal">E-Wallet</Label>
|
||||
<Switch
|
||||
checked={acceptEWallet}
|
||||
onCheckedChange={setAcceptEWallet}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSavePayments} disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Security Settings */}
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security & Privacy</CardTitle>
|
||||
<CardDescription>
|
||||
Manage security settings and access controls
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Two-Factor Authentication</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Require 2FA for all admin accounts
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={twoFactorAuth}
|
||||
onCheckedChange={setTwoFactorAuth}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="session-timeout">
|
||||
Session Timeout (minutes)
|
||||
</Label>
|
||||
<Input
|
||||
id="session-timeout"
|
||||
type="number"
|
||||
value={sessionTimeout}
|
||||
onChange={(e) => setSessionTimeout(e.target.value)}
|
||||
min="15"
|
||||
max="480"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password-expiry">
|
||||
Password Expiry (days)
|
||||
</Label>
|
||||
<Input
|
||||
id="password-expiry"
|
||||
type="number"
|
||||
value={passwordExpiry}
|
||||
onChange={(e) => setPasswordExpiry(e.target.value)}
|
||||
min="30"
|
||||
max="365"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-attempts">
|
||||
Maximum Login Attempts
|
||||
</Label>
|
||||
<Input
|
||||
id="login-attempts"
|
||||
type="number"
|
||||
value={loginAttempts}
|
||||
onChange={(e) => setLoginAttempts(e.target.value)}
|
||||
min="3"
|
||||
max="10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveSecurity} disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1090
components/admin/users-table.tsx
Normal file
1090
components/admin/users-table.tsx
Normal file
File diff suppressed because it is too large
Load Diff
81
components/calendar/calendar-12.tsx
Normal file
81
components/calendar/calendar-12.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type DateRange } from "react-day-picker"
|
||||
import { enUS, es } from "react-day-picker/locale"
|
||||
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
const localizedStrings = {
|
||||
en: {
|
||||
title: "Book an appointment",
|
||||
description: "Select the dates for your appointment",
|
||||
},
|
||||
es: {
|
||||
title: "Reserva una cita",
|
||||
description: "Selecciona las fechas para tu cita",
|
||||
},
|
||||
} as const
|
||||
|
||||
export default function Calendar12() {
|
||||
const [locale, setLocale] =
|
||||
React.useState<keyof typeof localizedStrings>("es")
|
||||
const [dateRange, setDateRange] = React.useState<DateRange | undefined>({
|
||||
from: new Date(2025, 8, 9),
|
||||
to: new Date(2025, 8, 17),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>{localizedStrings[locale].title}</CardTitle>
|
||||
<CardDescription>
|
||||
{localizedStrings[locale].description}
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Select
|
||||
value={locale}
|
||||
onValueChange={(value) =>
|
||||
setLocale(value as keyof typeof localizedStrings)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="Language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="es">Español</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={dateRange}
|
||||
onSelect={setDateRange}
|
||||
defaultMonth={dateRange?.from}
|
||||
numberOfMonths={2}
|
||||
locale={locale === "es" ? es : enUS}
|
||||
className="bg-transparent p-0"
|
||||
buttonVariant="outline"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
32
components/calendar/calendar-14.tsx
Normal file
32
components/calendar/calendar-14.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
|
||||
export default function Calendar14() {
|
||||
const [date, setDate] = React.useState<Date | undefined>(
|
||||
new Date(2025, 5, 12)
|
||||
)
|
||||
const bookedDates = Array.from(
|
||||
{ length: 12 },
|
||||
(_, i) => new Date(2025, 5, 15 + i)
|
||||
)
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
mode="single"
|
||||
defaultMonth={date}
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
disabled={bookedDates}
|
||||
modifiers={{
|
||||
booked: bookedDates,
|
||||
}}
|
||||
modifiersClassNames={{
|
||||
booked: "[&>button]:line-through opacity-100",
|
||||
}}
|
||||
className="rounded-lg border shadow-sm"
|
||||
/>
|
||||
)
|
||||
}
|
||||
297
components/chart/chart-area-interactive.tsx
Normal file
297
components/chart/chart-area-interactive.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
|
||||
export const description = "An interactive area chart";
|
||||
|
||||
type ChartDataPoint = {
|
||||
date: string;
|
||||
appointments: number;
|
||||
};
|
||||
|
||||
const defaultChartData: ChartDataPoint[] = [
|
||||
{ date: "2024-04-01", appointments: 12 },
|
||||
{ date: "2024-04-02", appointments: 8 },
|
||||
{ date: "2024-04-03", appointments: 15 },
|
||||
{ date: "2024-04-04", appointments: 22 },
|
||||
{ date: "2024-04-05", appointments: 18 },
|
||||
{ date: "2024-04-06", appointments: 25 },
|
||||
{ date: "2024-04-07", appointments: 10 },
|
||||
{ date: "2024-04-08", appointments: 16 },
|
||||
{ date: "2024-04-09", appointments: 9 },
|
||||
{ date: "2024-04-10", appointments: 19 },
|
||||
{ date: "2024-04-11", appointments: 27 },
|
||||
{ date: "2024-04-12", appointments: 21 },
|
||||
{ date: "2024-04-13", appointments: 14 },
|
||||
{ date: "2024-04-14", appointments: 11 },
|
||||
{ date: "2024-04-15", appointments: 17 },
|
||||
{ date: "2024-04-16", appointments: 13 },
|
||||
{ date: "2024-04-17", appointments: 24 },
|
||||
{ date: "2024-04-18", appointments: 28 },
|
||||
{ date: "2024-04-19", appointments: 20 },
|
||||
{ date: "2024-04-20", appointments: 12 },
|
||||
{ date: "2024-04-21", appointments: 16 },
|
||||
{ date: "2024-04-22", appointments: 19 },
|
||||
{ date: "2024-04-23", appointments: 23 },
|
||||
{ date: "2024-04-24", appointments: 26 },
|
||||
{ date: "2024-04-25", appointments: 15 },
|
||||
{ date: "2024-04-26", appointments: 8 },
|
||||
{ date: "2024-04-27", appointments: 29 },
|
||||
{ date: "2024-04-28", appointments: 18 },
|
||||
{ date: "2024-04-29", appointments: 22 },
|
||||
{ date: "2024-04-30", appointments: 25 },
|
||||
{ date: "2024-05-01", appointments: 14 },
|
||||
{ date: "2024-05-02", appointments: 20 },
|
||||
{ date: "2024-05-03", appointments: 17 },
|
||||
{ date: "2024-05-04", appointments: 24 },
|
||||
{ date: "2024-05-05", appointments: 28 },
|
||||
{ date: "2024-05-06", appointments: 30 },
|
||||
{ date: "2024-05-07", appointments: 21 },
|
||||
{ date: "2024-05-08", appointments: 16 },
|
||||
{ date: "2024-05-09", appointments: 19 },
|
||||
{ date: "2024-05-10", appointments: 23 },
|
||||
{ date: "2024-05-11", appointments: 26 },
|
||||
{ date: "2024-05-12", appointments: 18 },
|
||||
{ date: "2024-05-13", appointments: 13 },
|
||||
{ date: "2024-05-14", appointments: 27 },
|
||||
{ date: "2024-05-15", appointments: 25 },
|
||||
{ date: "2024-05-16", appointments: 22 },
|
||||
{ date: "2024-05-17", appointments: 29 },
|
||||
{ date: "2024-05-18", appointments: 24 },
|
||||
{ date: "2024-05-19", appointments: 17 },
|
||||
{ date: "2024-05-20", appointments: 20 },
|
||||
{ date: "2024-05-21", appointments: 11 },
|
||||
{ date: "2024-05-22", appointments: 10 },
|
||||
{ date: "2024-05-23", appointments: 21 },
|
||||
{ date: "2024-05-24", appointments: 19 },
|
||||
{ date: "2024-05-25", appointments: 16 },
|
||||
{ date: "2024-05-26", appointments: 14 },
|
||||
{ date: "2024-05-27", appointments: 28 },
|
||||
{ date: "2024-05-28", appointments: 18 },
|
||||
{ date: "2024-05-29", appointments: 12 },
|
||||
{ date: "2024-05-30", appointments: 23 },
|
||||
{ date: "2024-05-31", appointments: 17 },
|
||||
{ date: "2024-06-01", appointments: 15 },
|
||||
{ date: "2024-06-02", appointments: 26 },
|
||||
{ date: "2024-06-03", appointments: 13 },
|
||||
{ date: "2024-06-04", appointments: 25 },
|
||||
{ date: "2024-06-05", appointments: 11 },
|
||||
{ date: "2024-06-06", appointments: 19 },
|
||||
{ date: "2024-06-07", appointments: 22 },
|
||||
{ date: "2024-06-08", appointments: 24 },
|
||||
{ date: "2024-06-09", appointments: 27 },
|
||||
{ date: "2024-06-10", appointments: 16 },
|
||||
{ date: "2024-06-11", appointments: 10 },
|
||||
{ date: "2024-06-12", appointments: 28 },
|
||||
{ date: "2024-06-13", appointments: 12 },
|
||||
{ date: "2024-06-14", appointments: 25 },
|
||||
{ date: "2024-06-15", appointments: 21 },
|
||||
{ date: "2024-06-16", appointments: 23 },
|
||||
{ date: "2024-06-17", appointments: 29 },
|
||||
{ date: "2024-06-18", appointments: 14 },
|
||||
{ date: "2024-06-19", appointments: 20 },
|
||||
{ date: "2024-06-20", appointments: 26 },
|
||||
{ date: "2024-06-21", appointments: 17 },
|
||||
{ date: "2024-06-22", appointments: 22 },
|
||||
{ date: "2024-06-23", appointments: 30 },
|
||||
{ date: "2024-06-24", appointments: 15 },
|
||||
{ date: "2024-06-25", appointments: 16 },
|
||||
{ date: "2024-06-26", appointments: 24 },
|
||||
{ date: "2024-06-27", appointments: 27 },
|
||||
{ date: "2024-06-28", appointments: 18 },
|
||||
{ date: "2024-06-29", appointments: 13 },
|
||||
{ date: "2024-06-30", appointments: 25 },
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
appointments: {
|
||||
label: "Appointments",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function ChartAreaInteractive({ data }: { data?: ChartDataPoint[] }) {
|
||||
const isMobile = useIsMobile();
|
||||
const [timeRange, setTimeRange] = React.useState("90d");
|
||||
|
||||
// Use provided data or fallback to default
|
||||
const chartData = React.useMemo(() => {
|
||||
if (!data || data.length === 0) {
|
||||
return defaultChartData;
|
||||
}
|
||||
|
||||
// Fill in missing dates with 0 appointments
|
||||
const referenceDate = new Date();
|
||||
const filledData: ChartDataPoint[] = [];
|
||||
|
||||
// Create a map of existing data
|
||||
const dataMap = new Map(data.map((item) => [item.date, item.appointments]));
|
||||
|
||||
// Generate data for last 90 days
|
||||
for (let i = 89; i >= 0; i--) {
|
||||
const date = new Date(referenceDate);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split("T")[0];
|
||||
|
||||
filledData.push({
|
||||
date: dateStr,
|
||||
appointments: dataMap.get(dateStr) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
return filledData;
|
||||
}, [data]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile) {
|
||||
setTimeRange("7d");
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const filteredData = React.useMemo(() => {
|
||||
const referenceDate = new Date();
|
||||
let daysToSubtract = 90;
|
||||
if (timeRange === "30d") {
|
||||
daysToSubtract = 30;
|
||||
} else if (timeRange === "7d") {
|
||||
daysToSubtract = 7;
|
||||
}
|
||||
const startDate = new Date(referenceDate);
|
||||
startDate.setDate(startDate.getDate() - daysToSubtract);
|
||||
|
||||
return chartData.filter((item) => {
|
||||
const date = new Date(item.date);
|
||||
return date >= startDate;
|
||||
});
|
||||
}, [chartData, timeRange]);
|
||||
|
||||
return (
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardTitle>Appointment Trends</CardTitle>
|
||||
<CardDescription>
|
||||
<span className="hidden @[540px]/card:block">
|
||||
Appointments over time
|
||||
</span>
|
||||
<span className="@[540px]/card:hidden">Appointments</span>
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
onValueChange={setTimeRange}
|
||||
variant="outline"
|
||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
|
||||
>
|
||||
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
|
||||
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
|
||||
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger
|
||||
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
|
||||
size="sm"
|
||||
aria-label="Select a value"
|
||||
>
|
||||
<SelectValue placeholder="Last 3 months" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="90d" className="rounded-lg">
|
||||
Last 3 months
|
||||
</SelectItem>
|
||||
<SelectItem value="30d" className="rounded-lg">
|
||||
Last 30 days
|
||||
</SelectItem>
|
||||
<SelectItem value="7d" className="rounded-lg">
|
||||
Last 7 days
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={filteredData}>
|
||||
<defs>
|
||||
<linearGradient id="fillAppointments" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-appointments)"
|
||||
stopOpacity={1.0}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-appointments)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}}
|
||||
indicator="dot"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="appointments"
|
||||
type="natural"
|
||||
fill="url(#fillAppointments)"
|
||||
stroke="var(--color-appointments)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
807
components/chart/data-table.tsx
Normal file
807
components/chart/data-table.tsx
Normal file
@@ -0,0 +1,807 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type UniqueIdentifier,
|
||||
} from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconCircleCheckFilled,
|
||||
IconDotsVertical,
|
||||
IconGripVertical,
|
||||
IconLayoutColumns,
|
||||
IconLoader,
|
||||
IconPlus,
|
||||
IconTrendingUp,
|
||||
} from "@tabler/icons-react"
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
Row,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs"
|
||||
|
||||
export const schema = z.object({
|
||||
id: z.number(),
|
||||
header: z.string(),
|
||||
type: z.string(),
|
||||
status: z.string(),
|
||||
target: z.string(),
|
||||
limit: z.string(),
|
||||
reviewer: z.string(),
|
||||
})
|
||||
|
||||
// Create a separate component for the drag handle
|
||||
function DragHandle({ id }: { id: number }) {
|
||||
const { attributes, listeners } = useSortable({
|
||||
id,
|
||||
})
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground size-7 hover:bg-transparent"
|
||||
>
|
||||
<IconGripVertical className="text-muted-foreground size-3" />
|
||||
<span className="sr-only">Drag to reorder</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
{
|
||||
id: "drag",
|
||||
header: () => null,
|
||||
cell: ({ row }) => <DragHandle id={row.original.id} />,
|
||||
},
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "header",
|
||||
header: "Header",
|
||||
cell: ({ row }) => {
|
||||
return <TableCellViewer item={row.original} />
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: "Section Type",
|
||||
cell: ({ row }) => (
|
||||
<div className="w-32">
|
||||
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
||||
{row.original.type}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
||||
{row.original.status === "Done" ? (
|
||||
<IconCircleCheckFilled className="fill-green-500 dark:fill-green-400" />
|
||||
) : (
|
||||
<IconLoader />
|
||||
)}
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "target",
|
||||
header: () => <div className="w-full text-right">Target</div>,
|
||||
cell: ({ row }) => (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
|
||||
loading: `Saving ${row.original.header}`,
|
||||
success: "Done",
|
||||
error: "Error",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Label htmlFor={`${row.original.id}-target`} className="sr-only">
|
||||
Target
|
||||
</Label>
|
||||
<Input
|
||||
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
|
||||
defaultValue={row.original.target}
|
||||
id={`${row.original.id}-target`}
|
||||
/>
|
||||
</form>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "limit",
|
||||
header: () => <div className="w-full text-right">Limit</div>,
|
||||
cell: ({ row }) => (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
|
||||
loading: `Saving ${row.original.header}`,
|
||||
success: "Done",
|
||||
error: "Error",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Label htmlFor={`${row.original.id}-limit`} className="sr-only">
|
||||
Limit
|
||||
</Label>
|
||||
<Input
|
||||
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
|
||||
defaultValue={row.original.limit}
|
||||
id={`${row.original.id}-limit`}
|
||||
/>
|
||||
</form>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "reviewer",
|
||||
header: "Reviewer",
|
||||
cell: ({ row }) => {
|
||||
const isAssigned = row.original.reviewer !== "Assign reviewer"
|
||||
|
||||
if (isAssigned) {
|
||||
return row.original.reviewer
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Label htmlFor={`${row.original.id}-reviewer`} className="sr-only">
|
||||
Reviewer
|
||||
</Label>
|
||||
<Select>
|
||||
<SelectTrigger
|
||||
className="w-38 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate"
|
||||
size="sm"
|
||||
id={`${row.original.id}-reviewer`}
|
||||
>
|
||||
<SelectValue placeholder="Assign reviewer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
|
||||
<SelectItem value="Jamik Tashpulatov">
|
||||
Jamik Tashpulatov
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: () => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Make a copy</DropdownMenuItem>
|
||||
<DropdownMenuItem>Favorite</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
|
||||
const { transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: row.original.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
data-dragging={isDragging}
|
||||
ref={setNodeRef}
|
||||
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: transition,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataTable({
|
||||
data: initialData,
|
||||
}: {
|
||||
data: z.infer<typeof schema>[]
|
||||
}) {
|
||||
const [data, setData] = React.useState(() => initialData)
|
||||
const [rowSelection, setRowSelection] = React.useState({})
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({})
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
)
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const sortableId = React.useId()
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
)
|
||||
|
||||
const dataIds = React.useMemo<UniqueIdentifier[]>(
|
||||
() => data?.map(({ id }) => id) || [],
|
||||
[data]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
getRowId: (row) => row.id.toString(),
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
})
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (active && over && active.id !== over.id) {
|
||||
setData((data) => {
|
||||
const oldIndex = dataIds.indexOf(active.id)
|
||||
const newIndex = dataIds.indexOf(over.id)
|
||||
return arrayMove(data, oldIndex, newIndex)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue="outline"
|
||||
className="w-full flex-col justify-start gap-6"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 lg:px-6">
|
||||
<Label htmlFor="view-selector" className="sr-only">
|
||||
View
|
||||
</Label>
|
||||
<Select defaultValue="outline">
|
||||
<SelectTrigger
|
||||
className="flex w-fit @4xl/main:hidden"
|
||||
size="sm"
|
||||
id="view-selector"
|
||||
>
|
||||
<SelectValue placeholder="Select a view" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="outline">Outline</SelectItem>
|
||||
<SelectItem value="past-performance">Past Performance</SelectItem>
|
||||
<SelectItem value="key-personnel">Key Personnel</SelectItem>
|
||||
<SelectItem value="focus-documents">Focus Documents</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TabsList className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
|
||||
<TabsTrigger value="outline">Outline</TabsTrigger>
|
||||
<TabsTrigger value="past-performance">
|
||||
Past Performance <Badge variant="secondary">3</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="key-personnel">
|
||||
Key Personnel <Badge variant="secondary">2</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="focus-documents">Focus Documents</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span className="hidden lg:inline">Customize Columns</span>
|
||||
<span className="lg:hidden">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" &&
|
||||
column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconPlus />
|
||||
<span className="hidden lg:inline">Add Section</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent
|
||||
value="outline"
|
||||
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
|
||||
>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}
|
||||
id={sortableId}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted sticky top-0 z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody className="**:data-[slot=table-cell]:first:w-8">
|
||||
{table.getRowModel().rows?.length ? (
|
||||
<SortableContext
|
||||
items={dataIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<DraggableRow key={row.id} row={row} />
|
||||
))}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
<SelectValue
|
||||
placeholder={table.getState().pagination.pageSize}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="past-performance"
|
||||
className="flex flex-col px-4 lg:px-6"
|
||||
>
|
||||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
||||
</TabsContent>
|
||||
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
|
||||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="focus-documents"
|
||||
className="flex flex-col px-4 lg:px-6"
|
||||
>
|
||||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
const chartData = [
|
||||
{ month: "January", desktop: 186, mobile: 80 },
|
||||
{ month: "February", desktop: 305, mobile: 200 },
|
||||
{ month: "March", desktop: 237, mobile: 120 },
|
||||
{ month: "April", desktop: 73, mobile: 190 },
|
||||
{ month: "May", desktop: 209, mobile: 130 },
|
||||
{ month: "June", desktop: 214, mobile: 140 },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
mobile: {
|
||||
label: "Mobile",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<Drawer direction={isMobile ? "bottom" : "right"}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="link" className="text-foreground w-fit px-0 text-left">
|
||||
{item.header}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="gap-1">
|
||||
<DrawerTitle>{item.header}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Showing total visitors for the last 6 months
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
|
||||
{!isMobile && (
|
||||
<>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
margin={{
|
||||
left: 0,
|
||||
right: 10,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => value.slice(0, 3)}
|
||||
hide
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dot" />}
|
||||
/>
|
||||
<Area
|
||||
dataKey="mobile"
|
||||
type="natural"
|
||||
fill="var(--color-mobile)"
|
||||
fillOpacity={0.6}
|
||||
stroke="var(--color-mobile)"
|
||||
stackId="a"
|
||||
/>
|
||||
<Area
|
||||
dataKey="desktop"
|
||||
type="natural"
|
||||
fill="var(--color-desktop)"
|
||||
fillOpacity={0.4}
|
||||
stroke="var(--color-desktop)"
|
||||
stackId="a"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
<Separator />
|
||||
<div className="grid gap-2">
|
||||
<div className="flex gap-2 leading-none font-medium">
|
||||
Trending up by 5.2% this month{" "}
|
||||
<IconTrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Showing total visitors for the last 6 months. This is just
|
||||
some random text to test the layout. It spans multiple lines
|
||||
and should wrap around.
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<form className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="header">Header</Label>
|
||||
<Input id="header" defaultValue={item.header} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select defaultValue={item.type}>
|
||||
<SelectTrigger id="type" className="w-full">
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Table of Contents">
|
||||
Table of Contents
|
||||
</SelectItem>
|
||||
<SelectItem value="Executive Summary">
|
||||
Executive Summary
|
||||
</SelectItem>
|
||||
<SelectItem value="Technical Approach">
|
||||
Technical Approach
|
||||
</SelectItem>
|
||||
<SelectItem value="Design">Design</SelectItem>
|
||||
<SelectItem value="Capabilities">Capabilities</SelectItem>
|
||||
<SelectItem value="Focus Documents">
|
||||
Focus Documents
|
||||
</SelectItem>
|
||||
<SelectItem value="Narrative">Narrative</SelectItem>
|
||||
<SelectItem value="Cover Page">Cover Page</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select defaultValue={item.status}>
|
||||
<SelectTrigger id="status" className="w-full">
|
||||
<SelectValue placeholder="Select a status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Done">Done</SelectItem>
|
||||
<SelectItem value="In Progress">In Progress</SelectItem>
|
||||
<SelectItem value="Not Started">Not Started</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="target">Target</Label>
|
||||
<Input id="target" defaultValue={item.target} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="limit">Limit</Label>
|
||||
<Input id="limit" defaultValue={item.limit} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="reviewer">Reviewer</Label>
|
||||
<Select defaultValue={item.reviewer}>
|
||||
<SelectTrigger id="reviewer" className="w-full">
|
||||
<SelectValue placeholder="Select a reviewer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
|
||||
<SelectItem value="Jamik Tashpulatov">
|
||||
Jamik Tashpulatov
|
||||
</SelectItem>
|
||||
<SelectItem value="Emily Whalen">Emily Whalen</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Button>Submit</Button>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">Done</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
316
components/dentist/appointments-list.tsx
Normal file
316
components/dentist/appointments-list.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Calendar, Clock, Phone, Mail } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type Appointment = {
|
||||
id: string;
|
||||
date: Date;
|
||||
timeSlot: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
patient: {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
medicalHistory: string | null;
|
||||
};
|
||||
service: {
|
||||
name: string;
|
||||
duration: number;
|
||||
price: number;
|
||||
};
|
||||
payment: {
|
||||
status: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type DentistAppointmentsListProps = {
|
||||
appointments: Appointment[];
|
||||
};
|
||||
|
||||
export function DentistAppointmentsList({
|
||||
appointments,
|
||||
}: DentistAppointmentsListProps) {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState<string | null>(null);
|
||||
|
||||
const pendingAppointments = appointments.filter(
|
||||
(apt) => apt.status === "pending"
|
||||
);
|
||||
const upcomingAppointments = appointments.filter(
|
||||
(apt) => new Date(apt.date) >= new Date() && apt.status === "confirmed"
|
||||
);
|
||||
const completedAppointments = appointments.filter(
|
||||
(apt) => apt.status === "completed"
|
||||
);
|
||||
|
||||
const handleConfirmAppointment = async (appointmentId: string) => {
|
||||
setIsLoading(appointmentId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/appointments/${appointmentId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: "confirmed",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to confirm appointment");
|
||||
}
|
||||
|
||||
toast.success("Appointment confirmed successfully");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to confirm appointment");
|
||||
} finally {
|
||||
setIsLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeclineAppointment = async (appointmentId: string) => {
|
||||
if (!confirm("Are you sure you want to decline this appointment?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(appointmentId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/appointments/${appointmentId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: "cancelled",
|
||||
cancelReason: "Declined by dentist",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to decline appointment");
|
||||
}
|
||||
|
||||
toast.success("Appointment declined");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to decline appointment");
|
||||
} finally {
|
||||
setIsLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteAppointment = async (appointmentId: string) => {
|
||||
setIsLoading(appointmentId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/appointments/${appointmentId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: "completed",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to complete appointment");
|
||||
}
|
||||
|
||||
toast.success("Appointment marked as completed");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to complete appointment");
|
||||
} finally {
|
||||
setIsLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
pending: "secondary",
|
||||
confirmed: "default",
|
||||
cancelled: "destructive",
|
||||
completed: "outline",
|
||||
rescheduled: "secondary",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "default"}>
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAppointmentCard = (
|
||||
appointment: Appointment,
|
||||
showActions: boolean = true
|
||||
) => (
|
||||
<Card key={appointment.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{appointment.patient.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{appointment.service.name}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{getStatusBadge(appointment.status)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{new Date(appointment.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{appointment.timeSlot}</span>
|
||||
</div>
|
||||
{appointment.patient.phone && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{appointment.patient.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate">{appointment.patient.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appointment.patient.medicalHistory && (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Medical History:</p>
|
||||
<p className="text-muted-foreground">
|
||||
{appointment.patient.medicalHistory}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appointment.notes && (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Patient Notes:</p>
|
||||
<p className="text-muted-foreground">{appointment.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showActions && (
|
||||
<div className="flex gap-2">
|
||||
{appointment.status === "pending" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => handleConfirmAppointment(appointment.id)}
|
||||
disabled={isLoading === appointment.id}
|
||||
>
|
||||
{isLoading === appointment.id ? "Confirming..." : "Confirm"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => handleDeclineAppointment(appointment.id)}
|
||||
disabled={isLoading === appointment.id}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{appointment.status === "confirmed" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => handleCompleteAppointment(appointment.id)}
|
||||
disabled={isLoading === appointment.id}
|
||||
>
|
||||
{isLoading === appointment.id
|
||||
? "Completing..."
|
||||
: "Mark as Completed"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="pending" className="w-full">
|
||||
<TabsList className="grid w-full max-w-2xl grid-cols-3">
|
||||
<TabsTrigger value="pending">
|
||||
Pending ({pendingAppointments.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="upcoming">
|
||||
Upcoming ({upcomingAppointments.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="completed">
|
||||
Completed ({completedAppointments.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pending" className="space-y-4 mt-6">
|
||||
{pendingAppointments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">No pending appointments</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
pendingAppointments.map((apt) => renderAppointmentCard(apt))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="upcoming" className="space-y-4 mt-6">
|
||||
{upcomingAppointments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">No upcoming appointments</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
upcomingAppointments.map((apt) => renderAppointmentCard(apt))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="completed" className="space-y-4 mt-6">
|
||||
{completedAppointments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">No completed appointments</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
completedAppointments.map((apt) => renderAppointmentCard(apt, false))
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
139
components/dentist/patients-table.tsx
Normal file
139
components/dentist/patients-table.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Search, Mail, Phone } from "lucide-react"
|
||||
|
||||
type Patient = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
phone: string | null
|
||||
medicalHistory: string | null
|
||||
appointments: Array<{
|
||||
id: string
|
||||
date: Date
|
||||
status: string
|
||||
service: {
|
||||
name: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
type DentistPatientsTableProps = {
|
||||
patients: Patient[]
|
||||
}
|
||||
|
||||
export function DentistPatientsTable({ patients }: DentistPatientsTableProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const filteredPatients = patients.filter((patient) => {
|
||||
const query = searchQuery.toLowerCase()
|
||||
return (
|
||||
patient.name.toLowerCase().includes(query) ||
|
||||
patient.email.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>My Patients</CardTitle>
|
||||
<CardDescription>
|
||||
Total: {patients.length} patients
|
||||
</CardDescription>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Patient Name</TableHead>
|
||||
<TableHead>Contact</TableHead>
|
||||
<TableHead>Total Visits</TableHead>
|
||||
<TableHead>Last Visit</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPatients.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
No patients found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredPatients.map((patient) => {
|
||||
const completedVisits = patient.appointments.filter(
|
||||
(apt) => apt.status === "completed"
|
||||
).length
|
||||
const lastVisit = patient.appointments[0]
|
||||
|
||||
return (
|
||||
<TableRow key={patient.id}>
|
||||
<TableCell className="font-medium">{patient.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Mail className="h-3 w-3" />
|
||||
<span className="text-xs">{patient.email}</span>
|
||||
</div>
|
||||
{patient.phone && (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Phone className="h-3 w-3" />
|
||||
<span className="text-xs">{patient.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p>{patient.appointments.length} total</p>
|
||||
<p className="text-xs text-muted-foreground">{completedVisits} completed</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{lastVisit ? (
|
||||
<div>
|
||||
<p>{new Date(lastVisit.date).toLocaleDateString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{lastVisit.service.name}</p>
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="outline" size="sm">
|
||||
View History
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
358
components/emails/email-bookings.tsx
Normal file
358
components/emails/email-bookings.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface ServiceItem {
|
||||
description: string;
|
||||
qty: number;
|
||||
unitPrice: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DentalInvoiceProps {
|
||||
invoiceNumber: string;
|
||||
invoiceDate: string;
|
||||
dueDate: string;
|
||||
patientName: string;
|
||||
patientAddress: string;
|
||||
patientCity: string;
|
||||
patientPhone: string;
|
||||
patientEmail: string;
|
||||
bookingId: string;
|
||||
appointmentDate: string;
|
||||
appointmentTime: string;
|
||||
doctorName: string;
|
||||
treatmentRoom: string;
|
||||
appointmentDuration: string;
|
||||
reasonForVisit: string;
|
||||
pdfDownloadUrl: string;
|
||||
paymentStatus: string;
|
||||
nextAppointmentDate: string;
|
||||
nextAppointmentTime: string;
|
||||
nextAppointmentPurpose: string;
|
||||
services: ServiceItem[];
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
totalDue: number;
|
||||
}
|
||||
import {
|
||||
Html,
|
||||
Head,
|
||||
Body,
|
||||
Container,
|
||||
Section,
|
||||
Row,
|
||||
Column,
|
||||
Text,
|
||||
Heading,
|
||||
Hr,
|
||||
Button,
|
||||
Tailwind,
|
||||
} from "@react-email/components";
|
||||
|
||||
const DentalInvoice: React.FC<DentalInvoiceProps> = (props) => {
|
||||
return (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Tailwind>
|
||||
<Head />
|
||||
<Body className="bg-gray-100 font-sans py-[40px]">
|
||||
<Container className="bg-white max-w-[600px] mx-auto rounded-[8px] shadow-lg">
|
||||
{/* Header */}
|
||||
<Section className="bg-blue-600 text-white p-[32px] rounded-t-[8px]">
|
||||
<Heading className="text-[28px] font-bold m-0 text-center">
|
||||
DENTAL U CARE
|
||||
</Heading>
|
||||
<Text className="text-[16px] text-center m-0 mt-[8px] opacity-90">
|
||||
Professional Dental Services
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Invoice Header */}
|
||||
<Section className="p-[32px]">
|
||||
<Row>
|
||||
<Column>
|
||||
<Heading className="text-[24px] font-bold text-gray-800 m-0">
|
||||
INVOICE
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-gray-600 m-0 mt-[4px]">
|
||||
Invoice #: {props.invoiceNumber}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
Date: {props.invoiceDate}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
Due Date: {props.dueDate}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column align="right">
|
||||
<Text className="text-[14px] text-gray-600 m-0 font-semibold">
|
||||
Dental U Care Clinic
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
Baltan Street
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
Puerto Princesa City, Palawan 5300
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
Phone: (043) 756-1234
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Hr className="border-gray-200 mx-[32px]" />
|
||||
|
||||
{/* Patient Information */}
|
||||
<Section className="px-[32px] py-[24px]">
|
||||
<Heading className="text-[18px] font-semibold text-gray-800 m-0 mb-[16px]">
|
||||
Bill To:
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-gray-700 m-0 font-semibold">
|
||||
{props.patientName}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
{props.patientAddress}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
{props.patientCity}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
Phone: {props.patientPhone}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
Email: {props.patientEmail}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Hr className="border-gray-200 mx-[32px]" />
|
||||
|
||||
{/* Booking Details */}
|
||||
<Section className="px-[32px] py-[24px] bg-blue-50 mx-[32px] rounded-[8px]">
|
||||
<Heading className="text-[18px] font-semibold text-gray-800 m-0 mb-[16px]">
|
||||
Appointment Details:
|
||||
</Heading>
|
||||
<Row>
|
||||
<Column className="w-[50%]">
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Booking ID:</strong> {props.bookingId}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Appointment Date:</strong> {props.appointmentDate}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Time:</strong> {props.appointmentTime}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="w-[50%]">
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Doctor:</strong> {props.doctorName}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Treatment Room:</strong> {props.treatmentRoom}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Duration:</strong> {props.appointmentDuration}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mt-[12px]">
|
||||
<strong>Reason for Visit:</strong> {props.reasonForVisit}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Hr className="border-gray-200 mx-[32px] my-[24px]" />
|
||||
|
||||
{/* Services Table */}
|
||||
<Section className="px-[32px] py-[24px]">
|
||||
<Heading className="text-[18px] font-semibold text-gray-800 m-0 mb-[16px]">
|
||||
Services Rendered:
|
||||
</Heading>
|
||||
|
||||
{/* Table Header */}
|
||||
<Row className="bg-gray-50 border-solid border-[1px] border-gray-200">
|
||||
<Column className="p-[12px] w-[50%]">
|
||||
<Text className="text-[14px] font-semibold text-gray-700 m-0">
|
||||
Description
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="p-[12px] w-[15%] text-center">
|
||||
<Text className="text-[14px] font-semibold text-gray-700 m-0">
|
||||
Qty
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="p-[12px] w-[20%] text-center">
|
||||
<Text className="text-[14px] font-semibold text-gray-700 m-0">
|
||||
Unit Price
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="p-[12px] w-[15%] text-center">
|
||||
<Text className="text-[14px] font-semibold text-gray-700 m-0">
|
||||
Total
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
{/* Dynamic Service Items */}
|
||||
{props.services.map((service, index) => (
|
||||
<Row
|
||||
key={index}
|
||||
className="border-solid border-[1px] border-t-0 border-gray-200"
|
||||
>
|
||||
<Column className="p-[12px] w-[50%]">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
{service.description}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="p-[12px] w-[15%] text-center">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
{service.qty}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="p-[12px] w-[20%] text-center">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
₱{service.unitPrice.toFixed(2)}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="p-[12px] w-[15%] text-center">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
₱{service.total.toFixed(2)}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
{/* Totals */}
|
||||
<Section className="px-[32px]">
|
||||
<Row>
|
||||
<Column className="w-[70%]"></Column>
|
||||
<Column className="w-[30%]">
|
||||
<Row className="mb-[8px]">
|
||||
<Column className="w-[60%]">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
Subtotal:
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="w-[40%] text-right">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
₱{props.subtotal.toFixed(2)}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row className="mb-[8px]">
|
||||
<Column className="w-[60%]">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
Tax (12%):
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="w-[40%] text-right">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
₱{props.tax.toFixed(2)}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Hr className="border-gray-300 my-[8px]" />
|
||||
<Row>
|
||||
<Column className="w-[60%]">
|
||||
<Text className="text-[16px] font-bold text-gray-800 m-0">
|
||||
Total Due:
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="w-[40%] text-right">
|
||||
<Text className="text-[16px] font-bold text-blue-600 m-0">
|
||||
₱{props.totalDue.toFixed(2)}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* Download PDF Button */}
|
||||
<Section className="px-[32px] py-[24px] text-center">
|
||||
<Button
|
||||
href={props.pdfDownloadUrl}
|
||||
className="box-border bg-blue-600 text-white px-[32px] py-[16px] rounded-[8px] text-[16px] font-semibold no-underline inline-block"
|
||||
>
|
||||
📄 Download PDF Invoice
|
||||
</Button>
|
||||
<Text className="text-[12px] text-gray-500 m-0 mt-[12px]">
|
||||
Click the button above to download a PDF copy of this invoice
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Payment Information */}
|
||||
<Section className="px-[32px] py-[24px] bg-green-50 mx-[32px] my-[24px] rounded-[8px]">
|
||||
<Heading className="text-[16px] font-semibold text-gray-800 m-0 mb-[12px]">
|
||||
Payment Information
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Payment Status:</strong> {props.paymentStatus}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Payment Methods:</strong> Cash, Credit Card, Bank
|
||||
Transfer, GCash, PayMaya
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Bank Details:</strong> BPI - Account #1234567890 (Dental
|
||||
U Care Clinic)
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>GCash:</strong> 09171234567
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
<strong>Payment Terms:</strong> Payment due within 30 days of
|
||||
invoice date
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Next Appointment */}
|
||||
<Section className="px-[32px] py-[24px] bg-yellow-50 mx-[32px] rounded-[8px]">
|
||||
<Heading className="text-[16px] font-semibold text-gray-800 m-0 mb-[12px]">
|
||||
Next Appointment Reminder
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Follow-up Date:</strong> {props.nextAppointmentDate}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Time:</strong> {props.nextAppointmentTime}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
<strong>Purpose:</strong> {props.nextAppointmentPurpose}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Footer */}
|
||||
<Section className="px-[32px] py-[24px] text-center border-t-[1px] border-solid border-gray-200">
|
||||
<Text className="text-[14px] text-gray-600 m-0 mb-[8px]">
|
||||
Thank you for choosing Dental U Care for your oral health needs!
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0 mb-[8px]">
|
||||
For questions about this invoice, please contact us at
|
||||
billing@dentalucare.com or (043) 756-1234
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0">
|
||||
To reschedule or cancel appointments, call us at least 24 hours
|
||||
in advance
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Company Footer */}
|
||||
<Section className="px-[32px] py-[16px] bg-gray-50 text-center rounded-b-[8px]">
|
||||
<Text className="text-[12px] text-gray-500 m-0">
|
||||
Dental U Care Clinic | Baltan Street, Puerto Princesa City, Palawan
|
||||
5300
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0">
|
||||
© 2025 Dental U Care. All rights reserved. | License
|
||||
#DC-2025-001
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default DentalInvoice;
|
||||
198
components/emails/email-remainder.tsx
Normal file
198
components/emails/email-remainder.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Tailwind,
|
||||
Row,
|
||||
Column,
|
||||
Hr,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface DentalAppointmentReminderProps {
|
||||
patientName: string;
|
||||
appointmentDate: string;
|
||||
appointmentTime: string;
|
||||
doctorName: string;
|
||||
treatmentType: string;
|
||||
duration: string;
|
||||
clinicPhone: string;
|
||||
clinicEmail: string;
|
||||
clinicAddress: string;
|
||||
}
|
||||
|
||||
interface DentalAppointmentReminderComponent
|
||||
extends React.FC<DentalAppointmentReminderProps> {
|
||||
PreviewProps?: DentalAppointmentReminderProps;
|
||||
}
|
||||
|
||||
const DentalAppointmentReminder: DentalAppointmentReminderComponent = (
|
||||
props: DentalAppointmentReminderProps
|
||||
) => {
|
||||
return (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Tailwind>
|
||||
<Head />
|
||||
<Preview>
|
||||
Your upcoming appointment at Dental U Care - {props.appointmentDate}
|
||||
</Preview>
|
||||
<Body className="bg-gray-100 font-sans py-[40px]">
|
||||
<Container className="bg-white rounded-[8px] shadow-lg max-w-[600px] mx-auto p-[40px]">
|
||||
{/* Header */}
|
||||
<Section className="text-center mb-[32px]">
|
||||
<Heading className="text-[28px] font-bold text-blue-600 m-0 mb-[8px]">
|
||||
Dental U Care
|
||||
</Heading>
|
||||
<Text className="text-[16px] text-gray-600 m-0">
|
||||
Your Smile, Our Priority
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Main Content */}
|
||||
<Section className="mb-[32px]">
|
||||
<Heading className="text-[24px] font-bold text-gray-800 mb-[16px]">
|
||||
Appointment Reminder
|
||||
</Heading>
|
||||
<Text className="text-[16px] text-gray-700 mb-[24px] leading-[24px]">
|
||||
Dear {props.patientName},
|
||||
</Text>
|
||||
<Text className="text-[16px] text-gray-700 mb-[24px] leading-[24px]">
|
||||
This is a friendly reminder about your upcoming dental
|
||||
appointment at Dental U Care.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Appointment Details */}
|
||||
<Section className="bg-blue-50 rounded-[8px] p-[24px] mb-[32px]">
|
||||
<Heading className="text-[20px] font-bold text-blue-800 mb-[16px]">
|
||||
Appointment Details
|
||||
</Heading>
|
||||
<Row className="mb-[12px]">
|
||||
<Column className="w-[120px]">
|
||||
<Text className="text-[14px] font-semibold text-gray-600 m-0">
|
||||
Date:
|
||||
</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text className="text-[14px] text-gray-800 m-0">
|
||||
{props.appointmentDate}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row className="mb-[12px]">
|
||||
<Column className="w-[120px]">
|
||||
<Text className="text-[14px] font-semibold text-gray-600 m-0">
|
||||
Time:
|
||||
</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text className="text-[14px] text-gray-800 m-0">
|
||||
{props.appointmentTime}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row className="mb-[12px]">
|
||||
<Column className="w-[120px]">
|
||||
<Text className="text-[14px] font-semibold text-gray-600 m-0">
|
||||
Doctor:
|
||||
</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text className="text-[14px] text-gray-800 m-0">
|
||||
{props.doctorName}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row className="mb-[12px]">
|
||||
<Column className="w-[120px]">
|
||||
<Text className="text-[14px] font-semibold text-gray-600 m-0">
|
||||
Treatment:
|
||||
</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text className="text-[14px] text-gray-800 m-0">
|
||||
{props.treatmentType}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row>
|
||||
<Column className="w-[120px]">
|
||||
<Text className="text-[14px] font-semibold text-gray-600 m-0">
|
||||
Duration:
|
||||
</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text className="text-[14px] text-gray-800 m-0">
|
||||
{props.duration}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* Important Notes */}
|
||||
<Section className="mb-[32px]">
|
||||
<Heading className="text-[18px] font-bold text-gray-800 mb-[16px]">
|
||||
Important Reminders
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-gray-700 mb-[8px] leading-[20px]">
|
||||
• Please arrive 10 minutes early for check-in
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 mb-[8px] leading-[20px]">
|
||||
• Bring a valid ID and insurance card
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 mb-[8px] leading-[20px]">
|
||||
• If you need to reschedule, please call us at least 24 hours in
|
||||
advance
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 mb-[8px] leading-[20px]">
|
||||
• Continue your regular oral hygiene routine before your visit
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Contact Information */}
|
||||
<Section className="mb-[32px]">
|
||||
<Heading className="text-[18px] font-bold text-gray-800 mb-[16px]">
|
||||
Need to Make Changes?
|
||||
</Heading>
|
||||
<Text className="text-[16px] text-gray-700 mb-[16px] leading-[24px]">
|
||||
If you need to reschedule or cancel your appointment, please
|
||||
contact us:
|
||||
</Text>
|
||||
<Text className="text-[16px] text-blue-600 font-semibold mb-[8px]">
|
||||
Phone: {props.clinicPhone}
|
||||
</Text>
|
||||
<Text className="text-[16px] text-blue-600 font-semibold mb-[16px]">
|
||||
Email: {props.clinicEmail}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Hr className="border-gray-300 mb-[32px]" />
|
||||
|
||||
{/* Footer */}
|
||||
<Section className="text-center">
|
||||
<Text className="text-[14px] text-gray-600 mb-[8px]">
|
||||
Dental U Care
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 mb-[8px] m-0">
|
||||
{props.clinicAddress}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 mb-[16px]">
|
||||
Phone: {props.clinicPhone} | Email: {props.clinicEmail}
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0">
|
||||
© {new Date().getFullYear()} Dental U Care. All rights
|
||||
reserved.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
export default DentalAppointmentReminder;
|
||||
115
components/emails/email-verification.tsx
Normal file
115
components/emails/email-verification.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Html,
|
||||
Head,
|
||||
Body,
|
||||
Container,
|
||||
Section,
|
||||
Text,
|
||||
Button,
|
||||
Hr,
|
||||
Tailwind,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface VerificationEmailProps {
|
||||
username ?: string;
|
||||
verificationUrl?: string;
|
||||
}
|
||||
|
||||
const VerificationEmail = (props: VerificationEmailProps) => {
|
||||
const { username, verificationUrl } = props;
|
||||
|
||||
return (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Tailwind>
|
||||
<Head />
|
||||
<Body className="bg-gray-100 font-sans py-[40px]">
|
||||
<Container className="bg-white rounded-[8px] shadow-lg max-w-[600px] mx-auto p-[40px]">
|
||||
{/* Header */}
|
||||
<Section className="text-center mb-[32px]">
|
||||
<Text className="text-[32px] font-bold text-blue-600 m-0 mb-[8px]">
|
||||
Dental U Care
|
||||
</Text>
|
||||
<Text className="text-[16px] text-gray-600 m-0">
|
||||
Your Trusted Dental Care Partner
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Main Content */}
|
||||
<Section>
|
||||
<Text className="text-[24px] font-bold text-gray-800 mb-[24px] m-0">
|
||||
Verify Your Email Address
|
||||
</Text>
|
||||
<Text className="text-[16px] text-gray-700 mb-[24px] m-0 leading-[24px]">
|
||||
Thank you {username} for choosing Dental U Care! To complete your account
|
||||
setup and ensure secure access to your dental care portal,
|
||||
please verify your email address by clicking the button below.
|
||||
</Text>
|
||||
|
||||
{/* Verification Button */}
|
||||
<Section className="text-center my-[32px]">
|
||||
<Button
|
||||
href={verificationUrl}
|
||||
className="bg-blue-600 text-white px-[32px] py-[16px] rounded-[8px] text-[16px] font-semibold no-underline box-border hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Verify Email Address
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[14px] text-gray-600 mb-[24px] m-0 leading-[20px]">
|
||||
If the button above doesn`t work, you can also copy and paste
|
||||
this link into your browser:
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] text-blue-600 mb-[32px] m-0 break-all">
|
||||
{verificationUrl}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] text-gray-700 mb-[24px] m-0 leading-[24px]">
|
||||
This verification link will expire in 24 hours for your
|
||||
security. If you didn`t create an account with Dental U Care,
|
||||
please ignore this email.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Hr className="border-gray-200 my-[32px]" />
|
||||
|
||||
{/* Footer */}
|
||||
<Section>
|
||||
<Text className="text-[14px] text-gray-600 mb-[16px] m-0">
|
||||
Need help? Contact our support team at{" "}
|
||||
<a
|
||||
href="mailto:support@dentalucare.com"
|
||||
className="text-blue-600 no-underline"
|
||||
>
|
||||
send@dentalucare.tech
|
||||
</a>{" "}
|
||||
or call us at (+63) 917-123-4567.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] text-gray-500 m-0 mb-[8px]">
|
||||
Dental U Care Clinic
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0 mb-[8px]">
|
||||
Baltan Street, Barangay San Miguel
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0 mb-[16px]">
|
||||
Puerto Princesa, Palawan, Philippines
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] text-gray-500 m-0">
|
||||
© 2025 Dental U Care. All rights reserved.{" "}
|
||||
<a href="#" className="text-blue-600 no-underline">
|
||||
Unsubscribe
|
||||
</a>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default VerificationEmail;
|
||||
134
components/emails/reset-password.tsx
Normal file
134
components/emails/reset-password.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Html,
|
||||
Head,
|
||||
Body,
|
||||
Container,
|
||||
Section,
|
||||
Text,
|
||||
Button,
|
||||
Hr,
|
||||
Tailwind,
|
||||
} from "@react-email/components";
|
||||
interface ForgotPasswordEmailProps {
|
||||
username?: string;
|
||||
resetUrl?: string;
|
||||
userEmail?: string;
|
||||
}
|
||||
|
||||
const ForgotPasswordEmail = (props: ForgotPasswordEmailProps) => {
|
||||
const { username, resetUrl, userEmail } = props;
|
||||
|
||||
return (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Tailwind>
|
||||
<Head />
|
||||
<Body className="bg-gray-100 font-sans py-[40px]">
|
||||
<Container className="bg-white rounded-[8px] shadow-lg max-w-[600px] mx-auto p-[40px]">
|
||||
{/* Header */}
|
||||
<Section className="text-center mb-[32px]">
|
||||
<Text className="text-[32px] font-bold text-blue-600 m-0 mb-[8px]">
|
||||
Dental U Care
|
||||
</Text>
|
||||
<Text className="text-[16px] text-gray-600 m-0">
|
||||
Your Trusted Dental Care Partner
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Main Content */}
|
||||
<Section>
|
||||
<Text className="text-[24px] font-bold text-gray-800 mb-[24px] m-0">
|
||||
Reset Your Password
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] text-gray-700 mb-[24px] m-0 leading-[24px]">
|
||||
Hello {username}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] text-gray-700 mb-[24px] m-0 leading-[24px]">
|
||||
We received a request to reset the password for your {userEmail} Dental U
|
||||
Care account. Don`t worry - it happens to the best of us! Click
|
||||
the button below to create a new password.
|
||||
</Text>
|
||||
|
||||
{/* Reset Password Button */}
|
||||
<Section className="text-center my-[32px]">
|
||||
<Button
|
||||
href={resetUrl}
|
||||
className="bg-green-600 text-white px-[32px] py-[16px] rounded-[8px] text-[16px] font-semibold no-underline box-border hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[14px] text-gray-600 mb-[24px] m-0 leading-[20px]">
|
||||
If the button above doesn`t work, you can also copy and paste
|
||||
this link into your browser:
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] text-blue-600 mb-[32px] m-0 break-all">
|
||||
{resetUrl}
|
||||
</Text>
|
||||
|
||||
<Section className="bg-yellow-50 border-l-[4px] border-yellow-400 p-[16px] mb-[24px] rounded-[4px]">
|
||||
<Text className="text-[14px] text-yellow-800 m-0 font-semibold mb-[8px]">
|
||||
Important Security Information:
|
||||
</Text>
|
||||
<Text className="text-[14px] text-yellow-700 m-0 leading-[20px]">
|
||||
• This reset link will expire in 1 hour for your security
|
||||
<br />
|
||||
• If you didn`t request this password reset, please ignore
|
||||
this email
|
||||
<br />• Your current password will remain unchanged until you
|
||||
create a new one
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[16px] text-gray-700 mb-[24px] m-0 leading-[24px]">
|
||||
For your account security, we recommend choosing a strong
|
||||
password that includes a mix of letters, numbers, and special
|
||||
characters.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Hr className="border-gray-200 my-[32px]" />
|
||||
|
||||
{/* Footer */}
|
||||
<Section>
|
||||
<Text className="text-[14px] text-gray-600 mb-[16px] m-0">
|
||||
Having trouble? Our support team is here to help at{" "}
|
||||
<a
|
||||
href="mailto:info@dentalucare.com"
|
||||
className="text-blue-600 no-underline"
|
||||
>
|
||||
info@dentalucare.com
|
||||
</a>{" "}
|
||||
or call us at (+63) 917-123-4567.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] text-gray-500 m-0 mb-[8px]">
|
||||
Dental U Care
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0 mb-[8px]">
|
||||
Baltan Street, Barangay San Miguel
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0 mb-[16px]">
|
||||
Puerto Princesa, Palawan, Philippines
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] text-gray-500 m-0">
|
||||
© 2025 Dental U Care. All rights reserved.{" "}
|
||||
<a href="#" className="text-blue-600 no-underline">
|
||||
Unsubscribe
|
||||
</a>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default ForgotPasswordEmail;
|
||||
169
components/landing/about.tsx
Normal file
169
components/landing/about.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Image from "next/image";
|
||||
|
||||
interface About3Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
mainImage?: {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
secondaryImage?: {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
breakout?: {
|
||||
src: string;
|
||||
alt: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
buttonUrl?: string;
|
||||
};
|
||||
companiesTitle?: string;
|
||||
companies?: Array<{
|
||||
src: string;
|
||||
alt: string;
|
||||
}>;
|
||||
achievementsTitle?: string;
|
||||
achievementsDescription?: string;
|
||||
achievements?: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const defaultCompanies = [
|
||||
{
|
||||
src: "/tooth.svg",
|
||||
alt: "tooth",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultAchievements = [
|
||||
{ label: "Happy Patients", value: "500+" },
|
||||
{ label: "Appointments Booked", value: "1000+" },
|
||||
{ label: "Satisfaction Rate", value: "98%" },
|
||||
{ label: "Expert Dentists", value: "4" },
|
||||
];
|
||||
|
||||
const About = ({
|
||||
title = "About Dental U Care",
|
||||
description = "We're a modern dental care provider committed to making quality dental services accessible through our innovative online appointment system. Experience hassle-free booking and world-class dental care.",
|
||||
mainImage = {
|
||||
src: "/clinic.jpg",
|
||||
alt: "Modern dental clinic interior",
|
||||
},
|
||||
secondaryImage = {
|
||||
src: "/team.jpg",
|
||||
alt: "Professional dental team",
|
||||
},
|
||||
breakout = {
|
||||
src: "/tooth.svg",
|
||||
alt: "Dental U Care Logo",
|
||||
title: "Book Your Appointment in Minutes",
|
||||
description:
|
||||
"Our easy-to-use online booking system lets you schedule appointments 24/7, choose your preferred dentist, and manage your dental health journey.",
|
||||
buttonText: "Book Now",
|
||||
buttonUrl: "patient/book-appointment",
|
||||
},
|
||||
companiesTitle = "Trusted Insurance Partners",
|
||||
companies = defaultCompanies,
|
||||
achievementsTitle = "Our Impact in Numbers",
|
||||
achievementsDescription = "Providing quality dental care and making appointments easier for thousands of patients across the Philippines.",
|
||||
achievements = defaultAchievements,
|
||||
}: About3Props = {}) => {
|
||||
return (
|
||||
<section className="py-32">
|
||||
<div className="container">
|
||||
<div className="mb-14 grid gap-5 text-center md:grid-cols-2 md:text-left">
|
||||
<h1 className="text-5xl font-semibold">{title}</h1>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="grid gap-7 lg:grid-cols-3">
|
||||
<Image
|
||||
src={mainImage.src}
|
||||
alt={mainImage.alt}
|
||||
className="size-full max-h-[620px] rounded-xl object-cover lg:col-span-2"
|
||||
width={600}
|
||||
height={400}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col gap-7 md:flex-row lg:flex-col">
|
||||
<div className="bg-muted flex flex-col justify-between gap-6 rounded-xl p-7 md:w-1/2 lg:w-auto">
|
||||
<Image
|
||||
src={breakout.src}
|
||||
alt={breakout.alt}
|
||||
className="mr-auto h-12"
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
<div>
|
||||
<p className="mb-2 text-lg font-semibold">{breakout.title}</p>
|
||||
<p className="text-muted-foreground">{breakout.description}</p>
|
||||
</div>
|
||||
<Button variant="outline" className="mr-auto" asChild>
|
||||
<a href={breakout.buttonUrl} target="_blank">
|
||||
{breakout.buttonText}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<Image
|
||||
src={secondaryImage.src}
|
||||
alt={secondaryImage.alt}
|
||||
className="grow basis-0 rounded-xl object-cover md:w-1/2 lg:min-h-0 lg:w-auto"
|
||||
width={600}
|
||||
height={400}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-32">
|
||||
<p className="text-center">{companiesTitle} </p>
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-8">
|
||||
{companies.map((company, idx) => (
|
||||
<div className="flex items-center gap-3" key={company.src + idx}>
|
||||
<Image
|
||||
src={company.src}
|
||||
alt={company.alt}
|
||||
className="h-6 w-auto md:h-8"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative overflow-hidden rounded-xl bg-gradient-to-br from-blue-50 via-purple-50 to-pink-50 dark:from-blue-950/30 dark:via-purple-950/30 dark:to-pink-950/30 p-10 md:p-16 shadow-xl">
|
||||
<div className="flex flex-col gap-4 text-center md:text-left relative z-10">
|
||||
<h2 className="text-4xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">{achievementsTitle}</h2>
|
||||
<p className="text-muted-foreground max-w-xl">
|
||||
{achievementsDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-10 flex flex-wrap justify-between gap-10 text-center relative z-10">
|
||||
{achievements.map((item, idx) => {
|
||||
const gradients = [
|
||||
"from-blue-500 to-cyan-500",
|
||||
"from-purple-500 to-pink-500",
|
||||
"from-green-500 to-emerald-500",
|
||||
"from-orange-500 to-red-500",
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col gap-4 group" key={item.label + idx}>
|
||||
<p className="font-semibold text-muted-foreground group-hover:text-foreground transition-colors">{item.label}</p>
|
||||
<span className={`text-4xl font-bold md:text-5xl bg-gradient-to-r ${gradients[idx % gradients.length]} bg-clip-text text-transparent`}>
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute -top-1 right-1 z-0 hidden h-full w-full bg-[linear-gradient(to_right,hsl(var(--primary))_1px,transparent_1px),linear-gradient(to_bottom,hsl(var(--primary))_1px,transparent_1px)] bg-[size:80px_80px] opacity-5 [mask-image:linear-gradient(to_bottom_right,#000,transparent,transparent)] md:block"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { About };
|
||||
89
components/landing/contact.tsx
Normal file
89
components/landing/contact.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface Contact2Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
web?: { label: string; url: string };
|
||||
}
|
||||
|
||||
const Contact = ({
|
||||
title = "Get In Touch",
|
||||
description = "Have questions about our services or need help with booking? We're here to help! Reach out to us and we'll get back to you as soon as possible.",
|
||||
phone = "(+63) 917-123-4567",
|
||||
email = "info@dentalucare.com",
|
||||
web = { label: "dentalucare.com", url: "https://dentalucare.com" },
|
||||
}: Contact2Props) => {
|
||||
return (
|
||||
<section className="py-32">
|
||||
<div className="container">
|
||||
<div className="mx-auto flex max-w-7xl flex-col justify-between gap-10 lg:flex-row lg:gap-20">
|
||||
<div className="mx-auto flex max-w-sm flex-col justify-between gap-10">
|
||||
<div className="text-center lg:text-left">
|
||||
<h1 className="mb-2 text-5xl font-semibold lg:mb-1 lg:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="mx-auto w-fit lg:mx-0">
|
||||
<h3 className="mb-6 text-center text-2xl font-semibold lg:text-left">
|
||||
Contact Details
|
||||
</h3>
|
||||
<ul className="ml-4 list-disc">
|
||||
<li>
|
||||
<span className="font-bold">Phone: </span>
|
||||
{phone}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-bold">Email: </span>
|
||||
<a href={`mailto:${email}`} className="underline">
|
||||
{email}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-bold">Web: </span>
|
||||
<a href={web.url} target="_blank" className="underline">
|
||||
{web.label}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 rounded-lg border p-10">
|
||||
<div className="flex gap-4">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="firstname">First Name</Label>
|
||||
<Input type="text" id="firstname" placeholder="First Name" />
|
||||
</div>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="lastname">Last Name</Label>
|
||||
<Input type="text" id="lastname" placeholder="Last Name" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input type="email" id="email" placeholder="Email" />
|
||||
</div>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="subject">Subject</Label>
|
||||
<Input type="text" id="subject" placeholder="Subject" />
|
||||
</div>
|
||||
<div className="grid w-full gap-1.5">
|
||||
<Label htmlFor="message">Message</Label>
|
||||
<Textarea placeholder="Type your message here." id="message" />
|
||||
</div>
|
||||
<Button className="w-full">Send Message</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Contact };
|
||||
133
components/landing/features.tsx
Normal file
133
components/landing/features.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
CreditCard,
|
||||
Bell,
|
||||
UserCheck,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { ShimmeringText } from "@/components/ui/shimmering-text";
|
||||
|
||||
const Features = () => {
|
||||
const services = [
|
||||
{
|
||||
icon: <Calendar className="h-6 w-6" />,
|
||||
title: "Easy Online Booking",
|
||||
description:
|
||||
"Book your dental appointment online anytime, anywhere. Choose your preferred date, time slot, and specific dental service with real-time availability.",
|
||||
items: [
|
||||
"Real-time Availability",
|
||||
"Choose Date & Time",
|
||||
"Service Selection",
|
||||
],
|
||||
gradient: "from-indigo-500 to-blue-500",
|
||||
bgColor: "bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-indigo-950/30 dark:to-blue-950/30",
|
||||
iconBg: "bg-gradient-to-br from-indigo-500 to-blue-500",
|
||||
},
|
||||
{
|
||||
icon: <UserCheck className="h-6 w-6" />,
|
||||
title: "Secure Patient Portal",
|
||||
description:
|
||||
"Create your secure account with email verification. Manage your profile, medical history, and view all your appointments in one dashboard.",
|
||||
items: ["Profile Management", "Medical History", "Appointment Overview"],
|
||||
gradient: "from-teal-500 to-cyan-500",
|
||||
bgColor: "bg-gradient-to-br from-teal-50 to-cyan-50 dark:from-teal-950/30 dark:to-cyan-950/30",
|
||||
iconBg: "bg-gradient-to-br from-teal-500 to-cyan-500",
|
||||
},
|
||||
{
|
||||
icon: <Bell className="h-6 w-6" />,
|
||||
title: "Smart Reminders",
|
||||
description:
|
||||
"Never miss an appointment with automatic email and SMS reminders. Stay informed about upcoming visits and important updates.",
|
||||
items: ["Email Notifications", "SMS Reminders", "Real-time Updates"],
|
||||
gradient: "from-violet-500 to-purple-500",
|
||||
bgColor: "bg-gradient-to-br from-violet-50 to-purple-50 dark:from-violet-950/30 dark:to-purple-950/30",
|
||||
iconBg: "bg-gradient-to-br from-violet-500 to-purple-500",
|
||||
},
|
||||
{
|
||||
icon: <CreditCard className="h-6 w-6" />,
|
||||
title: "Flexible Payments",
|
||||
description:
|
||||
"Pay consultation and booking fees conveniently online via credit card, e-wallet, or bank transfer. Secure and hassle-free transactions.",
|
||||
items: ["Multiple Payment Methods", "Secure Checkout", "Payment History"],
|
||||
gradient: "from-emerald-500 to-green-500",
|
||||
bgColor: "bg-gradient-to-br from-emerald-50 to-green-50 dark:from-emerald-950/30 dark:to-green-950/30",
|
||||
iconBg: "bg-gradient-to-br from-emerald-500 to-green-500",
|
||||
},
|
||||
{
|
||||
icon: <Clock className="h-6 w-6" />,
|
||||
title: "Appointment Management",
|
||||
description:
|
||||
"Full control over your appointments. View, reschedule, or cancel upcoming visits easily through your patient dashboard.",
|
||||
items: ["View Appointments", "Reschedule Anytime", "Easy Cancellation"],
|
||||
gradient: "from-amber-500 to-orange-500",
|
||||
bgColor: "bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/30",
|
||||
iconBg: "bg-gradient-to-br from-amber-500 to-orange-500",
|
||||
},
|
||||
{
|
||||
icon: <Search className="h-6 w-6" />,
|
||||
title: "Find Your Dentist",
|
||||
description:
|
||||
"Search for dentists by specialty or service. View detailed profiles with qualifications, experience, and patient reviews to make informed decisions.",
|
||||
items: ["Dentist Profiles", "Read Reviews", "Compare Specialists"],
|
||||
gradient: "from-pink-500 to-rose-500",
|
||||
bgColor: "bg-gradient-to-br from-pink-50 to-rose-50 dark:from-pink-950/30 dark:to-rose-950/30",
|
||||
iconBg: "bg-gradient-to-br from-pink-500 to-rose-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-32">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-6xl space-y-12">
|
||||
<div className="space-y-4 text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
<ShimmeringText
|
||||
text="Features"
|
||||
className="bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 bg-clip-text text-transparent"
|
||||
shimmeringColor="rgb(147 51 234)"
|
||||
color="rgb(79 70 229)"
|
||||
duration={2}
|
||||
/>
|
||||
</h2>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg tracking-tight md:text-xl">
|
||||
Everything you need to manage your dental health journey. Book
|
||||
appointments, track your history, and connect with top dentists.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{services.map((service, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`${service.bgColor} space-y-6 rounded-xl border p-8 shadow-lg dark:shadow-xl dark:shadow-gray-900/50 transition-shadow duration-300 hover:shadow-2xl dark:hover:shadow-2xl dark:hover:shadow-gray-900/70`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`${service.iconBg} text-white rounded-full p-3 shadow-lg`}>
|
||||
{service.icon}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">{service.title}</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{service.items.map((item, itemIndex) => (
|
||||
<div key={itemIndex} className="flex items-center gap-2">
|
||||
<div className={`h-2 w-2 rounded-full bg-gradient-to-r ${service.gradient}`} />
|
||||
<span className="text-sm font-medium">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Features };
|
||||
148
components/landing/footer.tsx
Normal file
148
components/landing/footer.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from "react";
|
||||
import { FaFacebook, FaInstagram } from "react-icons/fa";
|
||||
import Image from "next/image";
|
||||
interface Footer7Props {
|
||||
logo?: {
|
||||
url: string;
|
||||
src: string;
|
||||
alt: string;
|
||||
title: string;
|
||||
};
|
||||
sections?: Array<{
|
||||
title: string;
|
||||
links: Array<{ name: string; href: string }>;
|
||||
}>;
|
||||
description?: string;
|
||||
socialLinks?: Array<{
|
||||
icon: React.ReactElement;
|
||||
href: string;
|
||||
label: string;
|
||||
}>;
|
||||
copyright?: string;
|
||||
legalLinks?: Array<{
|
||||
name: string;
|
||||
href: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const defaultSections = [
|
||||
{
|
||||
title: "Services",
|
||||
links: [
|
||||
{ name: "Preventive Care", href: "/services/preventive-care" },
|
||||
{ name: "Cosmetic Dentistry", href: "/services/cosmetic-dentistry" },
|
||||
{ name: "Orthodontics", href: "/services/orthodontics" },
|
||||
{ name: "Pediatric Dentistry", href: "/services/pediatric-dentistry" },
|
||||
{ name: "Emergency Care", href: "/services/emergency-care" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Patient Resources",
|
||||
links: [
|
||||
{ name: "Book Appointment", href: "/book" },
|
||||
{ name: "Patient Portal", href: "/dashboard" },
|
||||
{ name: "Insurance Info", href: "/insurance" },
|
||||
{ name: "Financing Options", href: "/financing" },
|
||||
{ name: "New Patient Forms", href: "/forms" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "About Us",
|
||||
links: [
|
||||
{ name: "Our Team", href: "/team" },
|
||||
{ name: "Our Clinic", href: "/clinic" },
|
||||
{ name: "Contact Us", href: "/contact" },
|
||||
{ name: "Careers", href: "/careers" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultSocialLinks = [
|
||||
{ icon: <FaInstagram className="size-5" />, href: "#", label: "Instagram" },
|
||||
{ icon: <FaFacebook className="size-5" />, href: "#", label: "Facebook" },
|
||||
];
|
||||
|
||||
const defaultLegalLinks = [
|
||||
{ name: "Terms and Conditions", href: "/docs/terms-and-conditions" },
|
||||
{ name: "Privacy Policy", href: "/docs/privacy-policy" },
|
||||
];
|
||||
|
||||
const Footer = ({
|
||||
logo = {
|
||||
url: "/",
|
||||
src: "/tooth.svg",
|
||||
alt: "Dental U Care Logo",
|
||||
title: "Dental U Care",
|
||||
},
|
||||
sections = defaultSections,
|
||||
description = "Your trusted online dental appointment system. Book appointments, manage your dental health, and connect with expert dentists - all in one place.",
|
||||
socialLinks = defaultSocialLinks,
|
||||
copyright = "© 2025 Dental U Care. All rights reserved.",
|
||||
legalLinks = defaultLegalLinks,
|
||||
}: Footer7Props) => {
|
||||
return (
|
||||
<section className="py-32">
|
||||
<div className="container">
|
||||
<div className="flex w-full flex-col justify-between gap-10 lg:flex-row lg:items-start lg:text-left">
|
||||
<div className="flex w-full flex-col justify-between gap-6 lg:items-start">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 lg:justify-start">
|
||||
<a href={logo.url}>
|
||||
<Image
|
||||
src={logo.src}
|
||||
alt={logo.alt}
|
||||
title={logo.title}
|
||||
className="h-8"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
</a>
|
||||
<h2 className="text-xl font-semibold">{logo.title}</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-[70%] text-sm">
|
||||
{description}
|
||||
</p>
|
||||
<ul className="text-muted-foreground flex items-center space-x-6">
|
||||
{socialLinks.map((social, idx) => (
|
||||
<li key={idx} className="hover:text-primary font-medium">
|
||||
<a href={social.href} aria-label={social.label}>
|
||||
{social.icon}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="grid w-full gap-6 md:grid-cols-3 lg:gap-20">
|
||||
{sections.map((section, sectionIdx) => (
|
||||
<div key={sectionIdx}>
|
||||
<h3 className="mb-4 font-bold">{section.title}</h3>
|
||||
<ul className="text-muted-foreground space-y-3 text-sm">
|
||||
{section.links.map((link, linkIdx) => (
|
||||
<li
|
||||
key={linkIdx}
|
||||
className="hover:text-primary font-medium"
|
||||
>
|
||||
<a href={link.href}>{link.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-8 flex flex-col justify-between gap-4 border-t py-8 text-xs font-medium md:flex-row md:items-center md:text-left">
|
||||
<p className="order-2 lg:order-1">{copyright}</p>
|
||||
<ul className="order-1 flex flex-col gap-2 md:order-2 md:flex-row">
|
||||
{legalLinks.map((link, idx) => (
|
||||
<li key={idx} className="hover:text-primary font-medium">
|
||||
<a href={link.href}>{link.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Footer };
|
||||
101
components/landing/get-started.tsx
Normal file
101
components/landing/get-started.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
|
||||
import React from "react"
|
||||
|
||||
export default function GetStartedGuide() {
|
||||
return (
|
||||
<section className="max-w-3xl mx-auto py-10 px-4">
|
||||
<h1 className="text-3xl font-bold mb-4 text-center">Dental U Care – Get Started Guide</h1>
|
||||
<p className="mb-6 text-center">Welcome to Dental U Care!<br />We’re excited to help you on your journey toward better oral health. Here’s how to get started as a new patient or team member.</p>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-2">For Patients</h2>
|
||||
<ol className="list-decimal list-inside space-y-4">
|
||||
<li>
|
||||
<strong>Register or Book Online</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Visit our website/app and click on “Book Appointment” or “Register as New Patient.”</li>
|
||||
<li>Fill in your details: Name, email, contact number, and medical history.</li>
|
||||
<li>Choose your preferred date, time, and service (e.g., cleaning, checkup, whitening).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Confirmation & Reminders</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>After booking, you’ll receive a confirmation via email or SMS with your appointment details.</li>
|
||||
<li>You can reschedule or cancel anytime via the link in your confirmation message.</li>
|
||||
<li>We’ll send you reminders 24 hours before your appointment.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Before Your Visit</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Bring a valid ID and your insurance card (if applicable).</li>
|
||||
<li>Arrive 10 minutes early for check-in and medical history review.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>On Your Appointment Day</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Our friendly staff will guide you through your visit.</li>
|
||||
<li>Feel free to ask about treatment plans or payment options.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Aftercare and Follow-Up</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>You may receive aftercare instructions by email or SMS.</li>
|
||||
<li>Book your next visit directly from our website or app whenever you’re ready.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-2">For Staff</h2>
|
||||
<ol className="list-decimal list-inside space-y-4">
|
||||
<li>
|
||||
<strong>Secure Login</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Use your assigned credentials to log in to the Dental U Care admin portal.</li>
|
||||
<li>Update your profile and verify your contact information.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Dashboard Overview</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Access the appointments dashboard to see upcoming bookings.</li>
|
||||
<li>Use the patient records section to review medical histories or notes.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Managing Appointments</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Confirm, reschedule, or cancel bookings as needed.</li>
|
||||
<li>Send reminders and follow-up messages to patients with a single click.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Treatment Documentation</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Log treatments, prescribe medicines, and upload documents directly to each patient record.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Communication</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Use the messaging tools to answer patient queries or coordinate internally.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6 mt-8">
|
||||
<h2 className="text-xl font-semibold mb-2">Need Help?</h2>
|
||||
<ul className="list-disc list-inside ml-5">
|
||||
<li>Call our help desk at <span className="font-medium">6-1234-5678</span>, or email <span className="font-medium">support@dentalucare.com</span>.</li>
|
||||
<li>Visit our FAQ on the website for common questions about appointments, insurance, or care tips.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
97
components/landing/hero.tsx
Normal file
97
components/landing/hero.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { ArrowRight, Calendar} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ShimmeringText } from "@/components/ui/shimmering-text";
|
||||
import TypingText from "@/components/ui/typing-text";
|
||||
import Link from "next/link";
|
||||
const Hero = () => {
|
||||
return (
|
||||
<section>
|
||||
<div className="container mt-5 ">
|
||||
<div className="bg-muted/25 grid items-center gap-8 lg:grid-cols-2 border rounded-lg shadow-lg">
|
||||
<div className="flex flex-col items-center p-16 text-center lg:items-start lg:text-left">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Image
|
||||
src="/tooth.svg"
|
||||
alt="Dental U Care Logo"
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<ShimmeringText
|
||||
text="Dental U-Care"
|
||||
className="text-2xl font-bold"
|
||||
shimmeringColor="rgb(147 51 234)"
|
||||
color="rgb(37 99 235)"
|
||||
duration={2}
|
||||
/>
|
||||
</div>
|
||||
<h1 className="my-6 text-pretty text-4xl font-bold lg:text-6xl min-h-[120px] lg:min-h-[160px] flex items-start">
|
||||
<TypingText
|
||||
text={[
|
||||
"Your Smile, Our Priority",
|
||||
"Book Appointments Online",
|
||||
"Smile with Confidence",
|
||||
"Expert Dental Care for you"
|
||||
]}
|
||||
typingSpeed={80}
|
||||
deletingSpeed={50}
|
||||
pauseDuration={2000}
|
||||
loop={true}
|
||||
showCursor={true}
|
||||
cursorClassName="bg-primary"
|
||||
cursorCharacter="|"
|
||||
className="inline-block"
|
||||
/>
|
||||
</h1>
|
||||
<p className="text-muted-foreground mb-8 max-w-xl lg:text-xl">
|
||||
Book your dental appointment online in minutes. Choose your
|
||||
preferred date, time, and service. Real-time availability with
|
||||
instant confirmation.
|
||||
</p>
|
||||
<div className="flex w-full flex-col justify-center gap-2 sm:flex-row lg:justify-start">
|
||||
<Button size="lg">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Book Appointment Now
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="lg">
|
||||
<Link href="/get-started" className="flex items-center">
|
||||
Get Started
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-8 grid grid-cols-3 gap-6 text-center lg:text-left">
|
||||
<div>
|
||||
<p className="text-2xl font-bold">500+</p>
|
||||
<p className="text-muted-foreground text-xs">Happy Patients</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">4</p>
|
||||
<p className="text-muted-foreground text-xs">Expert Dentists</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">98%</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Satisfaction Rate
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-full w-full overflow-hidden rounded-r-lg sm:rounded-lg md:rounded-lg">
|
||||
<Image
|
||||
src="/smile.jpg"
|
||||
alt="Professional dental care at Dental U Care"
|
||||
width={600}
|
||||
height={500}
|
||||
priority
|
||||
className="h-full w-full object-cover dark:brightness-75"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Hero };
|
||||
31
components/landing/navbar-wrapper.tsx
Normal file
31
components/landing/navbar-wrapper.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
import { Navbar } from "./navbar";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function NavbarWrapper() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [isUserAdmin, setIsUserAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchUser() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/session");
|
||||
if (res.ok) {
|
||||
const session = await res.json();
|
||||
setUser(session.user);
|
||||
setIsUserAdmin(session.user?.role === "admin");
|
||||
} else {
|
||||
setUser(null);
|
||||
setIsUserAdmin(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("NavbarWrapper: Error fetching session", error);
|
||||
setUser(null);
|
||||
setIsUserAdmin(false);
|
||||
}
|
||||
}
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
return <Navbar user={user} isAdmin={isUserAdmin} />;
|
||||
}
|
||||
651
components/landing/navbar.tsx
Normal file
651
components/landing/navbar.tsx
Normal file
@@ -0,0 +1,651 @@
|
||||
"use client";
|
||||
import { MenuIcon, SearchIcon, LogOut, User, Shield } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import Image from "next/image";
|
||||
import { authClient } from "@/lib/auth-session/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group";
|
||||
import { ModeToggle } from "../ui/mode-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image?: string | null;
|
||||
role?: string;
|
||||
} | null;
|
||||
|
||||
type NavbarProps = {
|
||||
user?: User;
|
||||
isAdmin?: boolean;
|
||||
};
|
||||
|
||||
const Navbar = ({ user, isAdmin: userIsAdmin }: NavbarProps) => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
const handleScroll = () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
setIsScrolled(window.scrollY > 50);
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
||||
const [showRequiredDialog, setShowRequiredDialog] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const handleSignOut = async () => {
|
||||
setShowLogoutDialog(false);
|
||||
try {
|
||||
await authClient.signOut();
|
||||
toast.success("Signed out successfully");
|
||||
router.push("/sign-in");
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error("Failed to sign out");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle search submit
|
||||
const handleSearch = (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (searchValue.trim()) {
|
||||
router.push(`/search?query=${encodeURIComponent(searchValue.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
const Services = [
|
||||
{
|
||||
title: "Preventive Care",
|
||||
description:
|
||||
"Cleanings, exams, and routine check-ups to keep smiles healthy",
|
||||
href: "/services/preventive-care",
|
||||
},
|
||||
{
|
||||
title: "Cosmetic Dentistry",
|
||||
description: "Teeth whitening, veneers, and smile makeovers",
|
||||
href: "/services/cosmetic-dentistry",
|
||||
},
|
||||
{
|
||||
title: "Orthodontics",
|
||||
description: "Braces and clear aligners for children and adults",
|
||||
href: "/services/orthodontics",
|
||||
},
|
||||
{
|
||||
title: "Pediatric Dentistry",
|
||||
description: "Gentle, kid-friendly dental care for your little ones",
|
||||
href: "/services/pediatric-dentistry",
|
||||
},
|
||||
{
|
||||
title: "Emergency Care",
|
||||
description:
|
||||
"Same-day treatment for tooth pain, injuries, and urgent issues",
|
||||
href: "/services/emergency-care",
|
||||
},
|
||||
{
|
||||
title: "Patient Resources",
|
||||
description: "New patient forms, insurance info, and financing options",
|
||||
href: "/patient-resources",
|
||||
},
|
||||
];
|
||||
|
||||
// Filtered suggestions based on searchValue
|
||||
const suggestions =
|
||||
searchValue.trim().length > 0
|
||||
? Services.filter(
|
||||
(s) =>
|
||||
s.title.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||
s.description.toLowerCase().includes(searchValue.toLowerCase())
|
||||
)
|
||||
: [];
|
||||
|
||||
const aboutItems = [
|
||||
{
|
||||
title: "Our Story",
|
||||
description: "Learn about Dental U Care's mission and values",
|
||||
href: "/#about",
|
||||
},
|
||||
{
|
||||
title: "Our Team",
|
||||
description: "Meet our expert dental professionals",
|
||||
href: "/#team",
|
||||
},
|
||||
{
|
||||
title: "Features",
|
||||
description: "Discover our online booking system features",
|
||||
href: "/#features",
|
||||
},
|
||||
{
|
||||
title: "Pricing",
|
||||
description: "Transparent pricing for all dental services",
|
||||
href: "/#pricing",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="sticky top-0 z-50 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
"container transition-all duration-300",
|
||||
isScrolled && "px-6 lg:px-12"
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-full px-6 py-6 transition-all duration-300 ",
|
||||
isScrolled
|
||||
? "border-2 border-accent dark:border-gray-900 bg-background/80 shadow-lg"
|
||||
: "border-2 border-accent dark:border-gray-800 bg-background/80 shadow-lg"
|
||||
)}
|
||||
>
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/tooth.svg"
|
||||
alt="Dental U Care"
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<span className="text-2xl font-semibold bg-gradient-to-r from-blue-600 to-blue-800 text-transparent bg-clip-text tracking-tighter">
|
||||
Dental U Care
|
||||
</span>
|
||||
</Link>
|
||||
<NavigationMenu className="hidden lg:block">
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger className="bg-transparent hover:bg-transparent focus:bg-transparent data-[active]:bg-transparent data-[state=open]:bg-transparent">
|
||||
About
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<div className="grid w-[500px] grid-cols-2 p-3">
|
||||
{aboutItems.map((item, index) => (
|
||||
<NavigationMenuLink
|
||||
href={item.href}
|
||||
key={index}
|
||||
className="rounded-md p-3 transition-colors"
|
||||
>
|
||||
<div key={item.title}>
|
||||
<p className="text-foreground mb-1 font-semibold">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</NavigationMenuLink>
|
||||
))}
|
||||
</div>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger className="bg-transparent hover:bg-transparent focus:bg-transparent data-[active]:bg-transparent data-[state=open]:bg-transparent">
|
||||
Services
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<div className="grid w-[600px] grid-cols-2 p-3">
|
||||
{Services.map((service, index) => (
|
||||
<NavigationMenuLink
|
||||
href={service.href}
|
||||
key={index}
|
||||
className="rounded-md p-3 transition-colors"
|
||||
>
|
||||
<div key={service.title}>
|
||||
<p className="text-foreground mb-1 font-semibold">
|
||||
{service.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
</NavigationMenuLink>
|
||||
))}
|
||||
</div>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink
|
||||
href="/#pricing"
|
||||
className="group inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-all hover:text-accent-foreground hover:border-b-2 hover:border-primary focus:outline-none disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
Pricing
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink
|
||||
href="/#contact"
|
||||
className="group inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-all hover:text-accent-foreground hover:border-b-2 hover:border-primary focus:outline-none disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
Contact
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
<div className="hidden items-center gap-4 lg:flex">
|
||||
<div className="relative">
|
||||
<form onSubmit={handleSearch} className="contents">
|
||||
<InputGroup
|
||||
className={cn(
|
||||
"w-64 transition-all duration-300 border border-gray-300 hover:border-primary hover:shadow-sm dark:border-gray-700 dark:hover:border-primary rounded-md",
|
||||
isScrolled && "w-58"
|
||||
)}
|
||||
>
|
||||
<InputGroupInput
|
||||
ref={inputRef}
|
||||
placeholder="Search services..."
|
||||
className="border-0 focus-visible:ring-0"
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
setSearchValue(e.target.value);
|
||||
setShowSuggestions(true);
|
||||
}}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onBlur={() =>
|
||||
setTimeout(() => setShowSuggestions(false), 100)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSearch();
|
||||
}}
|
||||
aria-label="Search services"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="h-4 w-4" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="sm" type="submit">
|
||||
Search
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<ul className="absolute left-0 z-50 mt-1 w-full bg-background border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-56 overflow-auto">
|
||||
{suggestions.map((s) => (
|
||||
<li
|
||||
key={s.title}
|
||||
className="px-4 py-2 cursor-pointer hover:bg-accent"
|
||||
onMouseDown={() => {
|
||||
setShowSuggestions(false);
|
||||
setSearchValue("");
|
||||
router.push(s.href);
|
||||
}}
|
||||
>
|
||||
<span className="font-semibold">{s.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{s.description}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
<ModeToggle />
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
<Button
|
||||
className={cn(isScrolled ? "hidden" : "lg:inline-flex")}
|
||||
asChild
|
||||
>
|
||||
<Link href="/patient/book-appointment">Book Now</Link>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage
|
||||
src={user.image || undefined}
|
||||
alt={user.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{user.name}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{userIsAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin" className="cursor-pointer">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{user?.role === "dentist" && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/dentist" className="cursor-pointer">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{user?.role === "patient" && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/patient" className="cursor-pointer">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowLogoutDialog(true)}
|
||||
className="cursor-pointer text-red-600"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Sign Out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(isScrolled ? "hidden" : "lg:inline-flex")}
|
||||
>
|
||||
<Link href="/sign-in">Sign In</Link>
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(isScrolled ? "hidden" : "lg:inline-flex")}
|
||||
onClick={() => setShowRequiredDialog(true)}
|
||||
>
|
||||
Book Now
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Sheet>
|
||||
<SheetTrigger asChild className="lg:hidden">
|
||||
<Button variant="outline" size="icon">
|
||||
<MenuIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="top" className="max-h-screen overflow-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
<a href="#" className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/tooth.svg"
|
||||
alt="Dental U Care"
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<span className="text-lg font-semibold tracking-tighter">
|
||||
Dental U Care
|
||||
</span>
|
||||
</a>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col p-4">
|
||||
<Accordion type="single" collapsible className="mb-2 mt-4">
|
||||
<AccordionItem value="about" className="border-none">
|
||||
<AccordionTrigger className="text-base hover:no-underline">
|
||||
About
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid gap-2">
|
||||
{aboutItems.map((item, index) => (
|
||||
<a
|
||||
href={item.href}
|
||||
key={index}
|
||||
className="rounded-md p-3 transition-colors"
|
||||
>
|
||||
<div key={item.title}>
|
||||
<p className="text-foreground mb-1 font-semibold">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="solutions" className="border-none">
|
||||
<AccordionTrigger className="text-base hover:no-underline">
|
||||
Services
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid md:grid-cols-2">
|
||||
{Services.map((service, index) => (
|
||||
<a
|
||||
href={service.href}
|
||||
key={index}
|
||||
className="rounded-md p-3 transition-colors"
|
||||
>
|
||||
<div key={service.title}>
|
||||
<p className="text-foreground mb-1 font-semibold">
|
||||
{service.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<div className="flex flex-col gap-6">
|
||||
<Link href="/#contact" className="font-medium">
|
||||
Contact
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-col gap-4">
|
||||
{user ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 rounded-lg border p-4">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage
|
||||
src={user.image || undefined}
|
||||
alt={user.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{user.name}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground mt-1">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/patient/book-appointment">Book Now</Link>
|
||||
</Button>
|
||||
{userIsAdmin && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
Admin Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{user?.role === "dentist" && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/dentist">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Dentist Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{user?.role === "patient" && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/patient">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowLogoutDialog(true)}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline">
|
||||
<Link href="/sign-in">Sign in</Link>
|
||||
</Button>
|
||||
<Button onClick={() => setShowRequiredDialog(true)}>
|
||||
Book Now
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</nav>
|
||||
</div>
|
||||
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Log out</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to log out?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" onClick={handleSignOut}>
|
||||
Log out
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showRequiredDialog} onOpenChange={setShowRequiredDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign in required</DialogTitle>
|
||||
<DialogDescription>
|
||||
You need to sign in to book an appointment. Would you like to sign
|
||||
in now?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowRequiredDialog(false);
|
||||
router.push("/sign-in");
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Navbar };
|
||||
286
components/landing/pricing.tsx
Normal file
286
components/landing/pricing.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
interface Service {
|
||||
name: string;
|
||||
price: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ServiceCategory {
|
||||
id: string;
|
||||
title: string;
|
||||
badge: string;
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
const Pricing = () => {
|
||||
const [showRequiredDialog, setShowRequiredDialog] = useState(false);
|
||||
|
||||
const serviceCategories: ServiceCategory[] = [
|
||||
{
|
||||
id: "basic",
|
||||
title: "Basic Services",
|
||||
badge: "Essential",
|
||||
services: [
|
||||
{
|
||||
name: "Dental Consultation / Checkup",
|
||||
price: "₱500 – ₱1,500",
|
||||
description: "Basic dental examination to check overall condition of teeth and gums",
|
||||
},
|
||||
{
|
||||
name: "Oral Prophylaxis (Cleaning)",
|
||||
price: "₱1,200 – ₱3,000",
|
||||
description: "Regular teeth cleaning, recommended every 6 months",
|
||||
},
|
||||
{
|
||||
name: "Tooth X-Ray",
|
||||
price: "₱700 – ₱2,500+",
|
||||
description: "Depends on type (periapical, panoramic, etc.)",
|
||||
},
|
||||
{
|
||||
name: "Simple Tooth Extraction",
|
||||
price: "₱1,500 – ₱5,000",
|
||||
description: "Basic tooth extraction procedure",
|
||||
},
|
||||
{
|
||||
name: "Deep Cleaning / Scaling and Root Planing",
|
||||
price: "₱3,000 – ₱10,000+",
|
||||
description: "For early signs of gum disease, deeper cleaning procedure",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "fillings",
|
||||
title: "Dental Fillings",
|
||||
badge: "Restorative",
|
||||
services: [
|
||||
{
|
||||
name: "Amalgam Filling (Silver)",
|
||||
price: "₱800 – ₱2,500",
|
||||
description: "Traditional silver-colored filling material",
|
||||
},
|
||||
{
|
||||
name: "Composite Filling (Tooth-colored)",
|
||||
price: "₱1,500 – ₱4,500+",
|
||||
description: "Natural-looking tooth-colored filling",
|
||||
},
|
||||
{
|
||||
name: "Ceramic/Gold Filling",
|
||||
price: "₱5,000 – ₱15,000+",
|
||||
description: "Premium filling materials for durability",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "advanced",
|
||||
title: "Advanced Treatments",
|
||||
badge: "Popular",
|
||||
services: [
|
||||
{
|
||||
name: "Surgical/Impacted Tooth Extraction",
|
||||
price: "₱10,000 – ₱30,000+",
|
||||
description: "Complex extraction for impacted teeth",
|
||||
},
|
||||
{
|
||||
name: "Root Canal Treatment",
|
||||
price: "₱5,000 – ₱20,000+",
|
||||
description: "Treatment for infected tooth pulp, cleaned and sealed",
|
||||
},
|
||||
{
|
||||
name: "Dental Crowns (Basic - Metal or PFM)",
|
||||
price: "₱8,000 – ₱20,000+",
|
||||
description: "Cap for damaged tooth, metal or porcelain-fused-to-metal",
|
||||
},
|
||||
{
|
||||
name: "Dental Crowns (Premium - Zirconia, Emax)",
|
||||
price: "₱30,000 – ₱45,000+",
|
||||
description: "High-quality aesthetic crowns",
|
||||
},
|
||||
{
|
||||
name: "Teeth Whitening (Bleaching)",
|
||||
price: "₱9,000 – ₱30,000+",
|
||||
description: "Laser or in-clinic whitening procedure",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "replacement",
|
||||
title: "Tooth Replacement",
|
||||
badge: "Restoration",
|
||||
services: [
|
||||
{
|
||||
name: "Partial Denture",
|
||||
price: "₱10,000 – ₱30,000+",
|
||||
description: "Removable denture for missing teeth",
|
||||
},
|
||||
{
|
||||
name: "Full Denture",
|
||||
price: "Contact for pricing",
|
||||
description: "Complete denture set, depends on number of teeth",
|
||||
},
|
||||
{
|
||||
name: "Dental Bridges",
|
||||
price: "₱20,000 – ₱60,000+",
|
||||
description: "Replacement of missing teeth using adjacent teeth",
|
||||
},
|
||||
{
|
||||
name: "Dental Implants",
|
||||
price: "₱80,000 – ₱150,000+",
|
||||
description: "Permanent tooth replacement using titanium post + crown",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cosmetic",
|
||||
title: "Cosmetic & Orthodontics",
|
||||
badge: "Premium",
|
||||
services: [
|
||||
{
|
||||
name: "Dental Veneers",
|
||||
price: "₱12,000 – ₱35,000+ per tooth",
|
||||
description: "For aesthetic purposes - straight, white, beautiful teeth",
|
||||
},
|
||||
{
|
||||
name: "Traditional Metal Braces",
|
||||
price: "₱35,000 – ₱80,000+",
|
||||
description: "Classic metal braces for teeth alignment",
|
||||
},
|
||||
{
|
||||
name: "Ceramic / Clear Braces",
|
||||
price: "₱100,000 – ₱200,000+",
|
||||
description: "Aesthetic clear or tooth-colored braces",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-32 ml-10">
|
||||
<div className="container">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-8">
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-pretty text-4xl font-bold lg:text-6xl">
|
||||
Dental Services & Pricing
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-3xl mx-auto lg:text-xl">
|
||||
Transparent pricing for all your dental needs. Quality dental care at competitive rates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="basic" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-5 h-auto gap-2">
|
||||
{serviceCategories.map((category) => (
|
||||
<TabsTrigger
|
||||
key={category.id}
|
||||
value={category.id}
|
||||
className="text-sm sm:text-base"
|
||||
>
|
||||
{category.title}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{serviceCategories.map((category) => (
|
||||
<TabsContent
|
||||
key={category.id}
|
||||
value={category.id}
|
||||
className="mt-8 animate-in fade-in-50 slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-2xl lg:text-3xl">{category.title}</CardTitle>
|
||||
<Badge className="uppercase">{category.badge}</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Professional dental services with transparent pricing
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{category.services.map((service, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between p-4 rounded-lg border hover:border-primary transition-all duration-300 hover:shadow-md gap-2 animate-in fade-in-50 slide-in-from-left-4"
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start gap-2">
|
||||
<Check className="size-5 text-primary mt-1 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{service.name}</h3>
|
||||
{service.description && (
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right sm:text-left sm:ml-4">
|
||||
<span className="text-xl font-bold text-primary whitespace-nowrap">
|
||||
{service.price}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-center animate-in fade-in-50 zoom-in-95 duration-500 delay-300">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setShowRequiredDialog(true)}
|
||||
>
|
||||
<Link href="patient/book-appointment" className="flex items-center">
|
||||
Book Appointment
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={showRequiredDialog} onOpenChange={setShowRequiredDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Required To Sign In & Sign Up</DialogTitle>
|
||||
<DialogDescription>
|
||||
Please sign in or sign up to access this content.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline"><Link href="/sign-in">Sign In</Link></Button>
|
||||
</DialogClose>
|
||||
<Button variant="default">
|
||||
<Link href="/sign-up">Sign Up</Link>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Pricing };
|
||||
83
components/landing/privacy-policy.tsx
Normal file
83
components/landing/privacy-policy.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
return (
|
||||
<section className="max-w-3xl mx-auto py-10 px-4 text-gray-800">
|
||||
<h1 className="text-3xl font-bold mb-4 text-center">Privacy Policy</h1>
|
||||
<h2 className="text-xl font-semibold mb-2 text-center">Dental U Care</h2>
|
||||
<p className="mb-6 text-center">
|
||||
Dental U Care values your privacy and is committed to protecting your personal information. This Privacy Policy explains how we collect, use, store, and protect your data when you visit our clinic, website, or contact us through any communication channel.
|
||||
</p>
|
||||
<ol className="list-decimal pl-6 space-y-4">
|
||||
<li>
|
||||
<strong>Information We Collect</strong><br />
|
||||
We collect the following types of information to deliver quality dental care and manage our services effectively:<br />
|
||||
<ul className="list-disc pl-6">
|
||||
<li>Personal Information: Name, address, contact number, email, and date of birth.</li>
|
||||
<li>Medical and Dental Information: Health history, dental records, X-rays, treatment notes, and insurance details.</li>
|
||||
<li>Payment Information: Billing addresses, payment records, and insurance claim details.</li>
|
||||
<li>Digital Information: Information from your visits to our website, including cookies, browser type, and access time (if applicable).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>How We Use Your Information</strong><br />
|
||||
Your information is used solely for legitimate purposes related to your treatment and clinic operations:<br />
|
||||
<ul className="list-disc pl-6">
|
||||
<li>To provide dental diagnosis, care, and follow-up services.</li>
|
||||
<li>To maintain accurate internal records and manage appointments.</li>
|
||||
<li>To process billing, payments, and insurance claims.</li>
|
||||
<li>To communicate important updates, reminders, and follow-up instructions.</li>
|
||||
<li>To improve the quality and safety of our services.</li>
|
||||
<li>To comply with local laws, regulations, or legal obligations.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Data Protection and Security</strong><br />
|
||||
We implement security measures to protect your personal and health information from unauthorized access, misuse, or disclosure.<br />
|
||||
Patient records are stored securely in electronic or physical form and accessed only by authorized personnel.<br />
|
||||
Access to data is strictly limited to staff members who require it for care delivery or administrative purposes.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Information Sharing</strong><br />
|
||||
Dental U Care respects the confidentiality of your information. We do not sell or rent patient data. Information may be shared only:<br />
|
||||
<ul className="list-disc pl-6">
|
||||
<li>With authorized healthcare professionals involved in your care.</li>
|
||||
<li>With insurance providers for claim processing.</li>
|
||||
<li>With laboratories or specialists when necessary for your treatment.</li>
|
||||
<li>As required by law, such as health or regulatory reporting.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Retention of Records</strong><br />
|
||||
Patient records are retained for as long as required by Philippine law or professional standards. Once retention periods expire, records are securely disposed of.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Your Rights</strong><br />
|
||||
You have the right to:
|
||||
<ul className="list-disc pl-6">
|
||||
<li>Access and review your dental records.</li>
|
||||
<li>Request correction of inaccurate or incomplete data.</li>
|
||||
<li>Withdraw consent for certain uses, subject to legal and contractual limitations.</li>
|
||||
<li>File a privacy-related complaint with the clinic management.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cookies and Website Data (if applicable)</strong><br />
|
||||
If you use our website, cookies may be used to enhance your browsing experience. You can disable cookies through your browser settings, but some features may not function properly.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Policy Updates</strong><br />
|
||||
Dental U Care may update this Privacy Policy from time to time to comply with updated legal requirements or improve data management. Revisions will take effect immediately upon posting.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Contact Information</strong><br />
|
||||
For questions, requests, or concerns regarding this Privacy Policy, please contact:<br />
|
||||
Dental U Care<br />
|
||||
Address: <span className="italic">[Baltan Street, Puerto Princesa City, Palawan]</span><br />
|
||||
Phone: <span className="italic">[63+ 1234 5678]</span><br />
|
||||
Email: <span className="italic">[info@dentalucare.com]</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
132
components/landing/services.tsx
Normal file
132
components/landing/services.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Stethoscope,
|
||||
Sparkles,
|
||||
Brackets,
|
||||
Drill,
|
||||
Baby,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import { ShimmeringText } from "@/components/ui/shimmering-text";
|
||||
|
||||
const Services = () => {
|
||||
const services = [
|
||||
{
|
||||
icon: <Stethoscope className="h-6 w-6" />,
|
||||
title: "General Dentistry",
|
||||
description:
|
||||
"Comprehensive oral health care including routine checkups, professional cleanings, and preventive treatments to maintain your dental health.",
|
||||
items: [
|
||||
"Routine Checkups",
|
||||
"Professional Cleaning",
|
||||
"Cavity Fillings",
|
||||
],
|
||||
gradient: "from-blue-500 to-cyan-500",
|
||||
bgColor: "bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-950/30 dark:to-cyan-950/30",
|
||||
iconBg: "bg-gradient-to-br from-blue-500 to-cyan-500",
|
||||
},
|
||||
{
|
||||
icon: <Sparkles className="h-6 w-6" />,
|
||||
title: "Cosmetic Dentistry",
|
||||
description:
|
||||
"Transform your smile with our advanced cosmetic procedures. From teeth whitening to complete smile makeovers, we help you achieve the perfect smile.",
|
||||
items: ["Teeth Whitening", "Veneers", "Smile Makeover"],
|
||||
gradient: "from-purple-500 to-pink-500",
|
||||
bgColor: "bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/30 dark:to-pink-950/30",
|
||||
iconBg: "bg-gradient-to-br from-purple-500 to-pink-500",
|
||||
},
|
||||
{
|
||||
icon: <Brackets className="h-6 w-6" />,
|
||||
title: "Orthodontics",
|
||||
description:
|
||||
"Straighten your teeth and correct bite issues with traditional braces or modern clear aligners for both children and adults.",
|
||||
items: ["Traditional Braces", "Clear Aligners", "Retainers"],
|
||||
gradient: "from-green-500 to-emerald-500",
|
||||
bgColor: "bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/30 dark:to-emerald-950/30",
|
||||
iconBg: "bg-gradient-to-br from-green-500 to-emerald-500",
|
||||
},
|
||||
{
|
||||
icon: <Drill className="h-6 w-6" />,
|
||||
title: "Restorative Care",
|
||||
description:
|
||||
"Restore damaged or missing teeth with dental implants, crowns, bridges, and root canal therapy using advanced techniques.",
|
||||
items: ["Dental Implants", "Crowns & Bridges", "Root Canal Therapy"],
|
||||
gradient: "from-orange-500 to-red-500",
|
||||
bgColor: "bg-gradient-to-br from-orange-50 to-red-50 dark:from-orange-950/30 dark:to-red-950/30",
|
||||
iconBg: "bg-gradient-to-br from-orange-500 to-red-500",
|
||||
},
|
||||
{
|
||||
icon: <Baby className="h-6 w-6" />,
|
||||
title: "Pediatric Dentistry",
|
||||
description:
|
||||
"Gentle, kid-friendly dental care designed to make children feel comfortable and establish healthy oral hygiene habits from an early age.",
|
||||
items: ["Children's Checkups", "Fluoride Treatment", "Sealants"],
|
||||
gradient: "from-yellow-500 to-amber-500",
|
||||
bgColor: "bg-gradient-to-br from-yellow-50 to-amber-50 dark:from-yellow-950/30 dark:to-amber-950/30",
|
||||
iconBg: "bg-gradient-to-br from-yellow-500 to-amber-500",
|
||||
},
|
||||
{
|
||||
icon: <ShieldAlert className="h-6 w-6" />,
|
||||
title: "Emergency Dental Care",
|
||||
description:
|
||||
"Same-day emergency services for dental injuries, severe toothaches, broken teeth, and other urgent dental issues.",
|
||||
items: ["Tooth Pain Relief", "Broken Tooth Repair", "Same-Day Treatment"],
|
||||
gradient: "from-rose-500 to-red-600",
|
||||
bgColor: "bg-gradient-to-br from-rose-50 to-red-50 dark:from-rose-950/30 dark:to-red-950/30",
|
||||
iconBg: "bg-gradient-to-br from-rose-500 to-red-600",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-32">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-6xl space-y-12">
|
||||
<div className="space-y-4 text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
<ShimmeringText
|
||||
text="Our Dental Services"
|
||||
className="bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent"
|
||||
shimmeringColor="rgb(236 72 153)"
|
||||
color="rgb(37 99 235)"
|
||||
duration={2}
|
||||
/>
|
||||
</h2>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg tracking-tight md:text-xl">
|
||||
Comprehensive dental care tailored to your needs. From preventive treatments to advanced procedures, we provide exceptional care for the whole family.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{services.map((service, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`${service.bgColor} space-y-6 rounded-xl border p-8 shadow-lg dark:shadow-xl dark:shadow-gray-900/50 transition-shadow duration-300 hover:shadow-2xl dark:hover:shadow-2xl dark:hover:shadow-gray-900/70`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`${service.iconBg} text-white rounded-full p-3 shadow-lg`}>
|
||||
{service.icon}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">{service.title}</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{service.items.map((item, itemIndex) => (
|
||||
<div key={itemIndex} className="flex items-center gap-2">
|
||||
<div className={`h-2 w-2 rounded-full bg-gradient-to-r ${service.gradient}`} />
|
||||
<span className="text-sm font-medium">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Services };
|
||||
129
components/landing/team.tsx
Normal file
129
components/landing/team.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Github, Linkedin, Twitter } from "lucide-react";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
|
||||
interface TeamMember {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
avatar: string;
|
||||
github?: string;
|
||||
twitter?: string;
|
||||
linkedin?: string;
|
||||
}
|
||||
|
||||
interface Team1Props {
|
||||
heading?: string;
|
||||
subheading?: string;
|
||||
description?: string;
|
||||
members?: TeamMember[];
|
||||
}
|
||||
|
||||
const Team = ({
|
||||
heading = "Meet Our Expert Dental Team",
|
||||
description = "Our team of highly qualified dentists and specialists are dedicated to providing you with exceptional dental care. Each brings years of experience and a passion for creating healthy, beautiful smiles.",
|
||||
members = [
|
||||
{
|
||||
id: "member-1",
|
||||
name: "Kath Estrada",
|
||||
role: "Chief Dentist & Orthodontist",
|
||||
avatar:
|
||||
"/kath.jpg",
|
||||
|
||||
},
|
||||
{
|
||||
id: "member-2",
|
||||
name: "Clyrelle Jade Cervantes",
|
||||
role: "Cosmetic Dentistry Specialist",
|
||||
avatar:
|
||||
" /cervs.jpg",
|
||||
},
|
||||
{
|
||||
id: "member-3",
|
||||
name: "Von Vryan Arguelles",
|
||||
role: "Oral Surgeon",
|
||||
avatar:
|
||||
"/von.jpg",
|
||||
|
||||
},
|
||||
{
|
||||
id: "member-4",
|
||||
name: "Dexter Cabanag",
|
||||
role: "Periodontist",
|
||||
avatar:
|
||||
"/dexter.jpg",
|
||||
|
||||
},
|
||||
],
|
||||
}: Team1Props) => {
|
||||
return (
|
||||
<section className="py-24 lg:py-32">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mb-16 text-center">
|
||||
<h2 className="mb-6 text-3xl font-bold tracking-tight lg:text-5xl">
|
||||
{heading}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3 [&>*:last-child:nth-child(3n-2)]:col-start-2">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="border rounded-lg p-6 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-4">
|
||||
<Avatar className="size-20 lg:size-24">
|
||||
<AvatarImage src={member.avatar} />
|
||||
<AvatarFallback className="text-lg font-semibold">
|
||||
{member.name}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-1 text-lg font-semibold">{member.name}</h3>
|
||||
<p className="text-primary text-sm font-medium">
|
||||
{member.role}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{member.github && (
|
||||
<a
|
||||
href={member.github}
|
||||
className="bg-muted/50 rounded-lg p-2"
|
||||
>
|
||||
<Github className="text-muted-foreground size-4" />
|
||||
</a>
|
||||
)}
|
||||
{member.twitter && (
|
||||
<a
|
||||
href={member.twitter}
|
||||
className="bg-muted/50 rounded-lg p-2"
|
||||
>
|
||||
<Twitter className="text-muted-foreground size-4" />
|
||||
</a>
|
||||
)}
|
||||
{member.linkedin && (
|
||||
<a
|
||||
href={member.linkedin}
|
||||
className="bg-muted/50 rounded-lg p-2"
|
||||
>
|
||||
<Linkedin className="text-muted-foreground size-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Team };
|
||||
78
components/landing/terms-and-conditions.tsx
Normal file
78
components/landing/terms-and-conditions.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
|
||||
export default function TermsAndConditions() {
|
||||
return (
|
||||
<section className="max-w-3xl mx-auto py-10 px-4 text-gray-800">
|
||||
<h1 className="text-3xl font-bold mb-4 text-center">Terms and Conditions</h1>
|
||||
<h2 className="text-xl font-semibold mb-2 text-center">Dental U Care</h2>
|
||||
<p className="mb-6 text-center">
|
||||
Welcome to Dental U Care. By accessing our website, booking an appointment, or receiving services at our clinic, you agree to the following Terms and Conditions. Please read them carefully before engaging with our services.
|
||||
</p>
|
||||
<ol className="list-decimal pl-6 space-y-4">
|
||||
<li>
|
||||
<strong>General Terms</strong><br />
|
||||
Dental U Care provides dental healthcare services in accordance with professional and ethical standards.<br />
|
||||
These Terms apply to all patients, clients, and users who interact with our clinic, whether in person, by phone, or online.<br />
|
||||
We reserve the right to update these Terms at any time. Any changes will be posted on our premises or website.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Appointments and Cancellations</strong><br />
|
||||
Appointments can be booked via phone, online, or in person.<br />
|
||||
Please arrive at least 10 minutes before your scheduled time.<br />
|
||||
Appointment cancellations must be made at least 24 hours in advance. Late cancellations or missed appointments may result in a cancellation fee.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Payments and Billing</strong><br />
|
||||
Fees for dental services are determined based on the procedure and disclosed prior to treatment whenever possible.<br />
|
||||
Payment is required at the time of service unless otherwise arranged.<br />
|
||||
We accept cash, major credit/debit cards, and approved insurance.<br />
|
||||
Any additional laboratory or specialist fees will be discussed before proceeding.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Insurance and Claims</strong><br />
|
||||
Dental U Care assists patients in processing insurance claims but is not responsible for approval or denial of coverage.<br />
|
||||
Patients remain fully responsible for any amount not covered by insurance.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Patient Responsibilities</strong><br />
|
||||
Patients must provide accurate and complete medical and dental history information.<br />
|
||||
Any changes to health status, medications, or allergies must be reported promptly.<br />
|
||||
Patients are expected to follow the dentist’s recommended care and post-treatment instructions.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Treatment Consent</strong><br />
|
||||
Before any procedure, your dentist will explain the diagnosis, treatment options, and estimated costs.<br />
|
||||
By agreeing to proceed, you acknowledge that you understand the risks and benefits of the treatment.<br />
|
||||
Written consent may be required for certain procedures.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Privacy and Confidentiality</strong><br />
|
||||
Dental U Care complies with applicable privacy laws regarding the protection of personal and medical information.<br />
|
||||
Your records will not be shared without your consent, except as required by law or for insurance processing.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Disclaimer of Liability</strong><br />
|
||||
While our professionals strive to provide high-quality care, results may vary depending on individual conditions.<br />
|
||||
Dental U Care is not liable for post-treatment complications that arise from failure to follow prescribed care or external factors beyond our control.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Intellectual Property</strong><br />
|
||||
All content on our website and marketing materials—including text, logos, and images—is owned by Dental U Care and may not be copied or reproduced without permission.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Governing Law</strong><br />
|
||||
These Terms are governed by and construed in accordance with the laws of the Republic of the Philippines.<br />
|
||||
Any disputes shall be handled within the proper courts of Puerto Princesa City, Palawan.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Contact Information</strong><br />
|
||||
For questions regarding these Terms, please contact:<br />
|
||||
Dental U Care<br />
|
||||
Address: <span className="italic">[Baltan Street, Puerto Princesa City, Palawan]</span><br />
|
||||
Phone: <span className="italic">[63+ 1234 5678]</span><br />
|
||||
Email: <span className="italic">[info@dentalucare.com]</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
235
components/layout/app-sidebar.tsx
Normal file
235
components/layout/app-sidebar.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
IconChartBar,
|
||||
IconDashboard,
|
||||
IconDatabase,
|
||||
IconFileDescription,
|
||||
IconHelp,
|
||||
IconListDetails,
|
||||
IconReport,
|
||||
IconSettings,
|
||||
IconUsers,
|
||||
IconStethoscope,
|
||||
IconCalendar,
|
||||
IconMedicalCross,
|
||||
IconUserCog,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { NavDocuments } from "@/components/layout/nav-documents";
|
||||
import { NavMain } from "@/components/layout/nav-main";
|
||||
import { NavSecondary } from "@/components/layout/nav-secondary";
|
||||
import { NavUser } from "@/components/layout/nav-user";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
const adminData = {
|
||||
navMain: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/admin",
|
||||
icon: IconDashboard,
|
||||
},
|
||||
{
|
||||
title: "Appointments",
|
||||
url: "/admin/appointment-management",
|
||||
icon: IconCalendar,
|
||||
},
|
||||
{
|
||||
title: "Patients",
|
||||
url: "/admin/patient-management",
|
||||
icon: IconUsers,
|
||||
},
|
||||
{
|
||||
title: "Dentists",
|
||||
url: "/admin/dentist-management",
|
||||
icon: IconStethoscope,
|
||||
},
|
||||
{
|
||||
title: "Services",
|
||||
url: "/admin/service-management",
|
||||
icon: IconMedicalCross,
|
||||
},
|
||||
{
|
||||
title: "Users",
|
||||
url: "/admin/user-management",
|
||||
icon: IconUserCog,
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/admin/settings",
|
||||
icon: IconSettings,
|
||||
},
|
||||
{
|
||||
title: "Help & Support",
|
||||
url: "/admin/help-support",
|
||||
icon: IconHelp,
|
||||
},
|
||||
],
|
||||
documents: [
|
||||
{
|
||||
name: "Analytics",
|
||||
url: "/Dashboard",
|
||||
icon: IconChartBar,
|
||||
},
|
||||
{
|
||||
name: "Reports",
|
||||
url: "/Reports",
|
||||
icon: IconReport,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const patientData = {
|
||||
navMain: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/patient",
|
||||
icon: IconDashboard,
|
||||
},
|
||||
{
|
||||
title: "Book Appointment",
|
||||
url: "/patient/book-appointment",
|
||||
icon: IconCalendar,
|
||||
},
|
||||
{
|
||||
title: "My Appointments",
|
||||
url: "/patient/appointments",
|
||||
icon: IconListDetails,
|
||||
},
|
||||
{
|
||||
title: "Payments",
|
||||
url: "/patient/payments",
|
||||
icon: IconFileDescription,
|
||||
},
|
||||
{
|
||||
title: "Health Records",
|
||||
url: "/patient/health-records",
|
||||
icon: IconDatabase,
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/patient/settings",
|
||||
icon: IconSettings,
|
||||
},
|
||||
{
|
||||
title: "Help & Support",
|
||||
url: "#",
|
||||
icon: IconHelp,
|
||||
},
|
||||
],
|
||||
documents: [],
|
||||
};
|
||||
|
||||
const dentistData = {
|
||||
navMain: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/dentist",
|
||||
icon: IconDashboard,
|
||||
},
|
||||
{
|
||||
title: "My Appointments",
|
||||
url: "/dentist/appointments",
|
||||
icon: IconCalendar,
|
||||
},
|
||||
{
|
||||
title: "My Patients",
|
||||
url: "/dentist/patients",
|
||||
icon: IconUsers,
|
||||
},
|
||||
{
|
||||
title: "Schedule",
|
||||
url: "/dentist/schedule",
|
||||
icon: IconListDetails,
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/dentist/settings",
|
||||
icon: IconSettings,
|
||||
},
|
||||
{
|
||||
title: "Help & Support",
|
||||
url: "#",
|
||||
icon: IconHelp,
|
||||
},
|
||||
],
|
||||
documents: [],
|
||||
};
|
||||
|
||||
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
|
||||
user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image?: string | null;
|
||||
role?: string | null;
|
||||
} | null;
|
||||
isAdmin?: boolean;
|
||||
};
|
||||
|
||||
export function AppSidebar({ user, isAdmin, ...props }: AppSidebarProps) {
|
||||
// Determine which data to use based on user role
|
||||
const role = user?.role || "patient";
|
||||
const data =
|
||||
role === "admin"
|
||||
? adminData
|
||||
: role === "dentist"
|
||||
? dentistData
|
||||
: patientData;
|
||||
const homeUrl =
|
||||
role === "admin" ? "/admin" : role === "dentist" ? "/dentist" : "/";
|
||||
return (
|
||||
<>
|
||||
<Sidebar collapsible="offcanvas" {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||
>
|
||||
<Link href={homeUrl} className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/tooth.svg"
|
||||
alt="Dental U Care"
|
||||
width={24}
|
||||
height={24}
|
||||
className="!size-6"
|
||||
/>
|
||||
<span className="text-base font-semibold bg-gradient-to-r from-blue-600 to-pink-800 bg-clip-text text-transparent">
|
||||
Dental U-Care
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={data.navMain} />
|
||||
{data.documents.length > 0 && <NavDocuments items={data.documents} />}
|
||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
{user && <NavUser user={user} isAdmin={isAdmin} />}
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
components/layout/auth-layout-redirect.tsx
Normal file
38
components/layout/auth-layout-redirect.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { authClient } from "@/lib/auth-session/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Client-side redirect for authenticated users visiting auth pages
|
||||
* This runs after page load to avoid SSR/client hydration conflicts
|
||||
*/
|
||||
export function AuthLayoutRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Check session on client side only
|
||||
const checkSession = async () => {
|
||||
const { data: session, error } = await authClient.getSession();
|
||||
|
||||
if (!error && session?.user) {
|
||||
const user = session.user as { role?: string };
|
||||
const role = user.role;
|
||||
|
||||
// Redirect based on role
|
||||
if (role === "admin") {
|
||||
router.replace("/admin");
|
||||
} else if (role === "dentist") {
|
||||
router.replace("/dentist");
|
||||
} else if (role === "patient") {
|
||||
router.replace("/patient");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkSession();
|
||||
}, [router]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
}
|
||||
51
components/layout/dashboard-layout.tsx
Normal file
51
components/layout/dashboard-layout.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { SiteHeader } from "@/components/layout/site-header";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type UserRole = "admin" | "dentist" | "patient";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string | null | undefined;
|
||||
image?: string | null;
|
||||
};
|
||||
role: UserRole;
|
||||
children: ReactNode;
|
||||
variant?: "inset" | "sidebar";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared dashboard layout component that wraps pages with SidebarProvider,
|
||||
* AppSidebar, and SiteHeader to reduce code duplication across pages.
|
||||
*/
|
||||
export function DashboardLayout({
|
||||
user,
|
||||
role,
|
||||
children,
|
||||
variant = "inset",
|
||||
}: DashboardLayoutProps) {
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(var(--spacing) * 72)",
|
||||
"--header-height": "calc(var(--spacing) * 12)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar variant={variant} user={user} />
|
||||
<SidebarInset>
|
||||
<SiteHeader role={role} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
93
components/layout/nav-documents.tsx
Normal file
93
components/layout/nav-documents.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
IconDots,
|
||||
IconFolder,
|
||||
IconShare3,
|
||||
IconTrash,
|
||||
type Icon,
|
||||
} from "@tabler/icons-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
export function NavDocuments({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
name: string
|
||||
url: string
|
||||
icon: Icon
|
||||
}[]
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Documents</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.name}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
className="data-[state=open]:bg-accent rounded-sm"
|
||||
>
|
||||
<IconDots />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-24 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align={isMobile ? "end" : "start"}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<IconFolder />
|
||||
<span>Open</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconShare3 />
|
||||
<span>Share</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<IconTrash />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="text-sidebar-foreground/70">
|
||||
<IconDots className="text-sidebar-foreground/70" />
|
||||
<span>More</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
41
components/layout/nav-main.tsx
Normal file
41
components/layout/nav-main.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { type Icon } from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import Link from "next/link";
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: Icon;
|
||||
}[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent className="flex flex-col gap-2">
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton tooltip={item.title} asChild>
|
||||
<Link href={item.url}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
43
components/layout/nav-secondary.tsx
Normal file
43
components/layout/nav-secondary.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type Icon } from "@tabler/icons-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
export function NavSecondary({
|
||||
items,
|
||||
...props
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon: Icon
|
||||
}[]
|
||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||
return (
|
||||
<SidebarGroup {...props}>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
131
components/layout/nav-user.tsx
Normal file
131
components/layout/nav-user.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
IconCreditCard,
|
||||
IconDotsVertical,
|
||||
IconLogout,
|
||||
} from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import { authClient } from "@/lib/auth-session/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
isAdmin,
|
||||
}: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
image?: string | null;
|
||||
role?: string | null;
|
||||
};
|
||||
isAdmin?: boolean;
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authClient.signOut();
|
||||
toast.success("Signed out successfully");
|
||||
router.push("/sign-in");
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg ">
|
||||
<AvatarImage src={user.image ?? undefined} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{user.name?.[0] ?? "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
<IconDotsVertical className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.image ?? undefined} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{user.name?.[0] ?? "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin" className="cursor-pointer">
|
||||
<IconCreditCard className="mr-2" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{user?.role !== "admin" && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/profile" className="cursor-pointer">
|
||||
<IconCreditCard className="mr-2" />
|
||||
<span>Profile</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuGroup />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<IconLogout />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
117
components/layout/section-cards.tsx
Normal file
117
components/layout/section-cards.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { IconTrendingUp, IconTrendingDown } from "@tabler/icons-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
type DashboardStats = {
|
||||
totalAppointments: number
|
||||
appointmentChange: number
|
||||
newPatients: number
|
||||
patientChange: number
|
||||
revenue: number
|
||||
revenueChange: number
|
||||
satisfactionRate: number
|
||||
satisfactionChange: number
|
||||
}
|
||||
|
||||
export function SectionCards({ stats }: { stats: DashboardStats }) {
|
||||
return (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Total Appointments</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{stats.totalAppointments.toLocaleString()}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
{stats.appointmentChange >= 0 ? <IconTrendingUp /> : <IconTrendingDown />}
|
||||
{stats.appointmentChange >= 0 ? '+' : ''}{stats.appointmentChange.toFixed(1)}%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
{stats.appointmentChange >= 0 ? 'Bookings up this month' : 'Bookings down this month'}{' '}
|
||||
{stats.appointmentChange >= 0 ? <IconTrendingUp className="size-4" /> : <IconTrendingDown className="size-4" />}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Appointments for the last 30 days
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>New Patients</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{stats.newPatients.toLocaleString()}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
{stats.patientChange >= 0 ? <IconTrendingUp /> : <IconTrendingDown />}
|
||||
{stats.patientChange >= 0 ? '+' : ''}{stats.patientChange.toFixed(1)}%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
{stats.patientChange >= 0 ? 'Growing patient base' : 'Patient growth slowing'}{' '}
|
||||
{stats.patientChange >= 0 ? <IconTrendingUp className="size-4" /> : <IconTrendingDown className="size-4" />}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
New registrations this month
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Revenue This Month</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
₱{stats.revenue.toLocaleString('en-PH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
{stats.revenueChange >= 0 ? <IconTrendingUp /> : <IconTrendingDown />}
|
||||
{stats.revenueChange >= 0 ? '+' : ''}{stats.revenueChange.toFixed(1)}%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
{stats.revenueChange >= 0 ? 'Strong financial performance' : 'Revenue needs attention'}{' '}
|
||||
{stats.revenueChange >= 0 ? <IconTrendingUp className="size-4" /> : <IconTrendingDown className="size-4" />}
|
||||
</div>
|
||||
<div className="text-muted-foreground">Total revenue collected</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Patient Satisfaction</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{stats.satisfactionRate.toFixed(1)}%
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
{stats.satisfactionChange >= 0 ? <IconTrendingUp /> : <IconTrendingDown />}
|
||||
{stats.satisfactionChange >= 0 ? '+' : ''}{stats.satisfactionChange.toFixed(1)}%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
{stats.satisfactionRate >= 95 ? 'Excellent patient feedback' : 'Good patient feedback'}{' '}
|
||||
{stats.satisfactionChange >= 0 ? <IconTrendingUp className="size-4" /> : <IconTrendingDown className="size-4" />}
|
||||
</div>
|
||||
<div className="text-muted-foreground">Based on completed appointments</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
components/layout/site-header.tsx
Normal file
37
components/layout/site-header.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { ModeToggle } from "../ui/mode-toggle"
|
||||
|
||||
type SiteHeaderProps = {
|
||||
role?: string | null
|
||||
}
|
||||
|
||||
export function SiteHeader({ role }: SiteHeaderProps = {}) {
|
||||
const getTitle = () => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'Dental U-Care Admin Dashboard'
|
||||
case 'dentist':
|
||||
return 'Dental U-Care Dentist Portal'
|
||||
case 'patient':
|
||||
return 'Dental U-Care Patient Portal'
|
||||
default:
|
||||
return 'Dental U-Care'
|
||||
}
|
||||
}
|
||||
return (
|
||||
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
||||
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<h1 className="text-base font-medium">{getTitle()}</h1>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
455
components/patient/appointments-list.tsx
Normal file
455
components/patient/appointments-list.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Calendar, Clock, User, DollarSign, Eye } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type Appointment = {
|
||||
id: string;
|
||||
date: Date;
|
||||
timeSlot: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
dentist: {
|
||||
name: string;
|
||||
image: string | null;
|
||||
};
|
||||
service: {
|
||||
name: string;
|
||||
price: number | string;
|
||||
};
|
||||
payment: {
|
||||
status: string;
|
||||
amount: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type AppointmentsListProps = {
|
||||
appointments: Appointment[];
|
||||
};
|
||||
|
||||
export function AppointmentsList({ appointments }: AppointmentsListProps) {
|
||||
const [isLoading, setIsLoading] = useState<string | null>(null);
|
||||
const [selectedAppointment, setSelectedAppointment] =
|
||||
useState<Appointment | null>(null);
|
||||
|
||||
const upcomingAppointments = appointments.filter(
|
||||
(apt) => new Date(apt.date) >= new Date() && apt.status !== "cancelled"
|
||||
);
|
||||
const pastAppointments = appointments.filter(
|
||||
(apt) => new Date(apt.date) < new Date() || apt.status === "cancelled"
|
||||
);
|
||||
|
||||
const handleCancelAppointment = async (appointmentId: string) => {
|
||||
if (!confirm("Are you sure you want to cancel this appointment?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(appointmentId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/appointments/${appointmentId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: "cancelled",
|
||||
cancelReason: "Cancelled by patient",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to cancel appointment");
|
||||
}
|
||||
|
||||
toast.success("Appointment cancelled successfully");
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to cancel appointment");
|
||||
} finally {
|
||||
setIsLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
pending: "secondary",
|
||||
confirmed: "default",
|
||||
cancelled: "destructive",
|
||||
completed: "outline",
|
||||
rescheduled: "secondary",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "default"}>
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getPaymentBadge = (status: string) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
paid: "default",
|
||||
pending: "secondary",
|
||||
failed: "destructive",
|
||||
refunded: "outline",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "default"}>
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const formatPrice = (price: number | string): string => {
|
||||
if (typeof price === "string") {
|
||||
return price;
|
||||
}
|
||||
if (isNaN(price)) {
|
||||
return "Contact for pricing";
|
||||
}
|
||||
return `₱${price.toLocaleString()}`;
|
||||
};
|
||||
|
||||
const renderAppointmentCard = (appointment: Appointment) => (
|
||||
<Card key={appointment.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{appointment.service.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-1 mt-1">
|
||||
<User className="h-3 w-3" />
|
||||
Dr. {appointment.dentist.name}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{getStatusBadge(appointment.status)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{new Date(appointment.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{appointment.timeSlot}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{formatPrice(appointment.service.price)}</span>
|
||||
</div>
|
||||
{appointment.payment && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Payment:</span>
|
||||
{getPaymentBadge(appointment.payment.status)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{appointment.notes && (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Notes:</p>
|
||||
<p className="text-muted-foreground">{appointment.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appointment.status === "pending" ||
|
||||
appointment.status === "confirmed" ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setSelectedAppointment(appointment)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toast.info("Reschedule feature coming soon")}
|
||||
>
|
||||
Reschedule
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleCancelAppointment(appointment.id)}
|
||||
disabled={isLoading === appointment.id}
|
||||
>
|
||||
{isLoading === appointment.id ? "Cancelling..." : "Cancel"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setSelectedAppointment(appointment)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{appointment.status === "completed" &&
|
||||
appointment.payment?.status === "pending" && (
|
||||
<Button className="w-full" size="sm">
|
||||
Pay Now
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs defaultValue="upcoming" className="w-full">
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="upcoming">
|
||||
Upcoming ({upcomingAppointments.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="past">
|
||||
Past ({pastAppointments.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="upcoming" className="space-y-4 mt-6">
|
||||
{upcomingAppointments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No upcoming appointments
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() =>
|
||||
(window.location.href = "/patient/book-appointment")
|
||||
}
|
||||
>
|
||||
Book an Appointment
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
upcomingAppointments.map(renderAppointmentCard)
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="past" className="space-y-4 mt-6">
|
||||
{pastAppointments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">No past appointments</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
pastAppointments.map(renderAppointmentCard)
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Appointment Details Dialog */}
|
||||
<Dialog
|
||||
open={!!selectedAppointment}
|
||||
onOpenChange={() => setSelectedAppointment(null)}
|
||||
>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl">
|
||||
Appointment Details
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Booking ID: {selectedAppointment?.id}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{selectedAppointment &&
|
||||
getStatusBadge(selectedAppointment.status)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedAppointment && (
|
||||
<div className="space-y-6">
|
||||
{/* Service Information */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Service Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Service</p>
|
||||
<p className="font-medium">
|
||||
{selectedAppointment.service.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Price</p>
|
||||
<p className="font-medium">
|
||||
{formatPrice(selectedAppointment.service.price)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Appointment Details */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Appointment Schedule
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Date</p>
|
||||
<p className="font-medium">
|
||||
{new Date(selectedAppointment.date).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Time</p>
|
||||
<p className="font-medium">
|
||||
{selectedAppointment.timeSlot}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dentist Information */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Dentist Information
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your Dentist
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
Dr. {selectedAppointment.dentist.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Information */}
|
||||
{selectedAppointment.payment && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Payment Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Payment Status
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
{getPaymentBadge(selectedAppointment.payment.status)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Amount</p>
|
||||
<p className="font-medium">
|
||||
₱{selectedAppointment.payment.amount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{selectedAppointment.notes && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Special Requests / Notes
|
||||
</h3>
|
||||
<p className="text-sm bg-muted p-3 rounded-lg">
|
||||
{selectedAppointment.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
{selectedAppointment.status === "pending" ||
|
||||
selectedAppointment.status === "confirmed" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setSelectedAppointment(null);
|
||||
toast.info("Reschedule feature coming soon");
|
||||
}}
|
||||
>
|
||||
Reschedule
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
const id = selectedAppointment.id;
|
||||
setSelectedAppointment(null);
|
||||
handleCancelAppointment(id);
|
||||
}}
|
||||
disabled={isLoading === selectedAppointment.id}
|
||||
>
|
||||
Cancel Appointment
|
||||
</Button>
|
||||
</>
|
||||
) : selectedAppointment.status === "completed" &&
|
||||
selectedAppointment.payment?.status === "pending" ? (
|
||||
<Button className="w-full">Pay Now</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
404
components/patient/appointments-table.tsx
Normal file
404
components/patient/appointments-table.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconDotsVertical,
|
||||
IconLayoutColumns,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react"
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
type PatientAppointment = {
|
||||
id: string
|
||||
date: Date
|
||||
timeSlot: string
|
||||
status: string
|
||||
notes: string | null
|
||||
dentist: {
|
||||
name: string
|
||||
specialization: string | null
|
||||
}
|
||||
service: {
|
||||
name: string
|
||||
price: number
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
pending: "secondary",
|
||||
confirmed: "default",
|
||||
cancelled: "destructive",
|
||||
completed: "outline",
|
||||
rescheduled: "secondary",
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "default"} className="text-xs">
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: ColumnDef<PatientAppointment>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "serviceName",
|
||||
accessorFn: (row) => row.service.name,
|
||||
header: "Service",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.service.name}</p>
|
||||
<p className="text-xs text-muted-foreground">₱{row.original.service.price.toFixed(2)}</p>
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "dentistName",
|
||||
accessorFn: (row) => row.dentist.name,
|
||||
header: "Dentist",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">Dr. {row.original.dentist.name}</p>
|
||||
{row.original.dentist.specialization && (
|
||||
<p className="text-xs text-muted-foreground">{row.original.dentist.specialization}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "date",
|
||||
header: "Date & Time",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p>{new Date(row.original.date).toLocaleDateString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.original.timeSlot}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => getStatusBadge(row.original.status),
|
||||
},
|
||||
{
|
||||
accessorKey: "notes",
|
||||
header: "Notes",
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[200px] truncate">
|
||||
{row.original.notes || "-"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const canModify = row.original.status === "pending" || row.original.status === "confirmed"
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem>View Details</DropdownMenuItem>
|
||||
{canModify && (
|
||||
<>
|
||||
<DropdownMenuItem>Reschedule</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">Cancel</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
type PatientAppointmentsTableProps = {
|
||||
appointments: PatientAppointment[]
|
||||
}
|
||||
|
||||
export function PatientAppointmentsTable({ appointments }: PatientAppointmentsTableProps) {
|
||||
const [rowSelection, setRowSelection] = React.useState({})
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
const table = useReactTable({
|
||||
data: appointments,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search appointments..."
|
||||
value={(table.getColumn("serviceName")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn("serviceName")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span className="hidden lg:inline">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" && column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No appointments found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user