├── .prettierrc.json ├── src ├── app │ ├── favicon.ico │ ├── page.tsx │ ├── api │ │ ├── auth │ │ │ └── [...all] │ │ │ │ └── route.ts │ │ └── stripe │ │ │ └── webhook │ │ │ └── route.ts │ ├── (protected) │ │ ├── layout.tsx │ │ ├── doctors │ │ │ ├── _components │ │ │ │ ├── add-doctor-button.tsx │ │ │ │ └── doctor-card.tsx │ │ │ ├── _helpers │ │ │ │ └── availability.ts │ │ │ ├── page.tsx │ │ │ └── _constants │ │ │ │ └── index.ts │ │ ├── patients │ │ │ ├── _components │ │ │ │ ├── add-patient-button.tsx │ │ │ │ ├── table-columns.tsx │ │ │ │ ├── patient-card.tsx │ │ │ │ ├── table-actions.tsx │ │ │ │ └── upsert-patient-form.tsx │ │ │ └── page.tsx │ │ ├── clinic-form │ │ │ ├── page.tsx │ │ │ └── _components │ │ │ │ └── form.tsx │ │ ├── appointments │ │ │ ├── _components │ │ │ │ ├── add-appointment-button.tsx │ │ │ │ ├── table-columns.tsx │ │ │ │ └── table-actions.tsx │ │ │ └── page.tsx │ │ ├── subscription │ │ │ ├── page.tsx │ │ │ └── _components │ │ │ │ └── subscription-plan.tsx │ │ ├── dashboard │ │ │ ├── _components │ │ │ │ ├── stats-cards.tsx │ │ │ │ ├── top-doctors.tsx │ │ │ │ ├── date-picker.tsx │ │ │ │ ├── top-specialties.tsx │ │ │ │ └── appointments-chart.tsx │ │ │ └── page.tsx │ │ └── _components │ │ │ └── app-sidebar.tsx │ ├── layout.tsx │ ├── authentication │ │ ├── page.tsx │ │ └── components │ │ │ ├── sign-up-form.tsx │ │ │ └── login-form.tsx │ ├── new-subscription │ │ └── page.tsx │ └── globals.css ├── lib │ ├── utils.ts │ ├── auth-client.ts │ ├── next-safe-action.ts │ └── auth.ts ├── helpers │ ├── currency.ts │ └── time.ts ├── db │ ├── index.ts │ └── schema.ts ├── components │ └── ui │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ ├── label.tsx │ │ ├── separator.tsx │ │ ├── progress.tsx │ │ ├── input.tsx │ │ ├── page-container.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── popover.tsx │ │ ├── tooltip.tsx │ │ ├── tabs.tsx │ │ ├── button.tsx │ │ ├── data-table.tsx │ │ ├── card.tsx │ │ ├── table.tsx │ │ ├── calendar.tsx │ │ ├── dialog.tsx │ │ ├── form.tsx │ │ ├── alert-dialog.tsx │ │ ├── sheet.tsx │ │ ├── select.tsx │ │ ├── dropdown-menu.tsx │ │ └── chart.tsx ├── providers │ └── react-query.tsx ├── actions │ ├── add-appointment │ │ ├── schema.ts │ │ └── index.ts │ ├── upsert-patient │ │ ├── schema.ts │ │ └── index.ts │ ├── create-clinic │ │ └── index.ts │ ├── delete-doctor │ │ └── index.ts │ ├── delete-patient │ │ └── index.ts │ ├── delete-appointment │ │ └── index.ts │ ├── create-stripe-checkout │ │ └── index.ts │ ├── upsert-doctor │ │ ├── schema.ts │ │ └── index.ts │ └── get-available-times │ │ └── index.ts ├── hooks │ └── use-mobile.ts ├── middleware.ts ├── hocs │ └── with-authentication.tsx └── data │ └── get-dashboard.ts ├── postcss.config.mjs ├── public ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── next.config.ts ├── drizzle.config.ts ├── .vscode └── settings.json ├── components.json ├── .gitignore ├── tsconfig.json ├── eslint.config.mjs ├── README.md ├── package.json └── .cursor └── rules └── general.mdc /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"] 3 | } -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felipemotarocha/doutor-agenda-final/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | export default function Home() { 4 | redirect("/dashboard"); 5 | } 6 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue,clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/helpers/currency.ts: -------------------------------------------------------------------------------- 1 | export const formatCurrencyInCents = (amount: number) => { 2 | return new Intl.NumberFormat("pt-BR", { 3 | style: "currency", 4 | currency: "BRL", 5 | }).format(amount / 100); 6 | }; 7 | -------------------------------------------------------------------------------- /src/app/api/auth/[...all]/route.ts: -------------------------------------------------------------------------------- 1 | import { toNextJsHandler } from "better-auth/next-js"; 2 | 3 | import { auth } from "@/lib/auth"; // path to your auth file 4 | 5 | export const { POST, GET } = toNextJsHandler(auth); 6 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | 3 | import { drizzle } from "drizzle-orm/node-postgres"; 4 | 5 | import * as schema from "./schema"; 6 | 7 | export const db = drizzle(process.env.DATABASE_URL!, { 8 | schema, 9 | }); 10 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | logging: { 6 | fetches: { 7 | fullUrl: true, 8 | }, 9 | }, 10 | }; 11 | 12 | export default nextConfig; 13 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | export default defineConfig({ 4 | out: "./drizzle", 5 | schema: "./src/db/schema.ts", 6 | dialect: "postgresql", 7 | dbCredentials: { 8 | url: process.env.DATABASE_URL!, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /src/lib/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { customSessionClient } from "better-auth/client/plugins"; 2 | import { createAuthClient } from "better-auth/react"; 3 | 4 | import { auth } from "./auth"; 5 | 6 | export const authClient = createAuthClient({ 7 | plugins: [customSessionClient()], 8 | }); 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | { 4 | "mode": "auto" 5 | } 6 | ], 7 | "eslint.validate": [ 8 | "javascript", 9 | "javascriptreact", 10 | "typescript", 11 | "typescriptreact" 12 | ], 13 | "editor.codeActionsOnSave": { 14 | "source.fixAll.eslint": "explicit" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ) 11 | } 12 | 13 | export { Skeleton } 14 | -------------------------------------------------------------------------------- /src/helpers/time.ts: -------------------------------------------------------------------------------- 1 | export const generateTimeSlots = () => { 2 | const slots = []; 3 | for (let hour = 5; hour <= 23; hour++) { 4 | for (let minute = 0; minute < 60; minute += 30) { 5 | const timeString = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}:00`; 6 | slots.push(timeString); 7 | } 8 | } 9 | return slots; 10 | }; 11 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/providers/react-query.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | 5 | const queryClient = new QueryClient(); 6 | 7 | export const ReactQueryProvider = ({ 8 | children, 9 | }: { 10 | children: React.ReactNode; 11 | }) => { 12 | return ( 13 | {children} 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/app/(protected)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; 2 | 3 | import { AppSidebar } from "./_components/app-sidebar"; 4 | 5 | export default function Layout({ children }: { children: React.ReactNode }) { 6 | return ( 7 | 8 | 9 |
10 | 11 | {children} 12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /src/actions/add-appointment/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const addAppointmentSchema = z.object({ 4 | patientId: z.string().uuid({ 5 | message: "Paciente é obrigatório.", 6 | }), 7 | doctorId: z.string().uuid({ 8 | message: "Médico é obrigatório.", 9 | }), 10 | date: z.date({ 11 | message: "Data é obrigatória.", 12 | }), 13 | time: z.string().min(1, { 14 | message: "Horário é obrigatório.", 15 | }), 16 | appointmentPriceInCents: z.number().min(1, { 17 | message: "Valor da consulta é obrigatório.", 18 | }), 19 | }); 20 | -------------------------------------------------------------------------------- /src/actions/upsert-patient/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const upsertPatientSchema = z.object({ 4 | id: z.string().uuid().optional(), 5 | name: z.string().trim().min(1, { 6 | message: "Nome é obrigatório.", 7 | }), 8 | email: z.string().email({ 9 | message: "Email inválido.", 10 | }), 11 | phoneNumber: z.string().trim().min(1, { 12 | message: "Número de telefone é obrigatório.", 13 | }), 14 | sex: z.enum(["male", "female"], { 15 | required_error: "Sexo é obrigatório.", 16 | }), 17 | }); 18 | 19 | export type UpsertPatientSchema = z.infer; 20 | -------------------------------------------------------------------------------- /src/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner, ToasterProps } from "sonner" 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 22 | ) 23 | } 24 | 25 | export { Toaster } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as LabelPrimitive from "@radix-ui/react-label" 4 | import * as React from "react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { getSessionCookie } from "better-auth/cookies"; 2 | import type { NextRequest } from "next/server"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export function middleware(request: NextRequest) { 6 | const sessionCookie = getSessionCookie(request); 7 | if (!sessionCookie) { 8 | return NextResponse.redirect(new URL("/authentication", request.url)); 9 | } 10 | return NextResponse.next(); 11 | } 12 | 13 | // See "Matching Paths" below to learn more 14 | export const config = { 15 | matcher: [ 16 | "/dashboard", 17 | "/patients", 18 | "/doctors", 19 | "/appointments", 20 | "/subscription", 21 | "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)", 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from "@eslint/eslintrc"; 2 | import simpleImportSort from "eslint-plugin-simple-import-sort"; 3 | import { dirname } from "path"; 4 | import { fileURLToPath } from "url"; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = dirname(__filename); 8 | 9 | const compat = new FlatCompat({ 10 | baseDirectory: __dirname, 11 | }); 12 | 13 | const eslintConfig = [ 14 | ...compat.extends("next/core-web-vitals", "next/typescript"), 15 | { 16 | plugins: { 17 | "simple-import-sort": simpleImportSort, 18 | }, 19 | rules: { 20 | "simple-import-sort/imports": "error", 21 | "simple-import-sort/exports": "error", 22 | }, 23 | }, 24 | ]; 25 | 26 | export default eslintConfig; 27 | -------------------------------------------------------------------------------- /src/actions/create-clinic/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { headers } from "next/headers"; 4 | import { redirect } from "next/navigation"; 5 | 6 | import { db } from "@/db"; 7 | import { clinicsTable, usersToClinicsTable } from "@/db/schema"; 8 | import { auth } from "@/lib/auth"; 9 | 10 | export const createClinic = async (name: string) => { 11 | const session = await auth.api.getSession({ 12 | headers: await headers(), 13 | }); 14 | if (!session?.user) { 15 | throw new Error("Unauthorized"); 16 | } 17 | const [clinic] = await db.insert(clinicsTable).values({ name }).returning(); 18 | await db.insert(usersToClinicsTable).values({ 19 | userId: session.user.id, 20 | clinicId: clinic.id, 21 | }); 22 | redirect("/dashboard"); 23 | }; 24 | -------------------------------------------------------------------------------- /src/app/(protected)/doctors/_components/add-doctor-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Plus } from "lucide-react"; 4 | import { useState } from "react"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { Dialog, DialogTrigger } from "@/components/ui/dialog"; 8 | 9 | import UpsertDoctorForm from "./upsert-doctor-form"; 10 | 11 | const AddDoctorButton = () => { 12 | const [isOpen, setIsOpen] = useState(false); 13 | return ( 14 | 15 | 16 | 20 | 21 | setIsOpen(false)} isOpen={isOpen} /> 22 | 23 | ); 24 | }; 25 | 26 | export default AddDoctorButton; 27 | -------------------------------------------------------------------------------- /src/app/(protected)/patients/_components/add-patient-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Plus } from "lucide-react"; 4 | import { useState } from "react"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { Dialog, DialogTrigger } from "@/components/ui/dialog"; 8 | 9 | import UpsertPatientForm from "./upsert-patient-form"; 10 | 11 | const AddPatientButton = () => { 12 | const [isOpen, setIsOpen] = useState(false); 13 | return ( 14 | 15 | 16 | 20 | 21 | setIsOpen(false)} isOpen={isOpen} /> 22 | 23 | ); 24 | }; 25 | 26 | export default AddPatientButton; 27 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 4 | import * as React from "react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Separator({ 9 | className, 10 | orientation = "horizontal", 11 | decorative = true, 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | 25 | ) 26 | } 27 | 28 | export { Separator } 29 | -------------------------------------------------------------------------------- /src/actions/upsert-patient/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { revalidatePath } from "next/cache"; 4 | 5 | import { db } from "@/db"; 6 | import { patientsTable } from "@/db/schema"; 7 | import { protectedWithClinicActionClient } from "@/lib/next-safe-action"; 8 | 9 | import { upsertPatientSchema } from "./schema"; 10 | 11 | export const upsertPatient = protectedWithClinicActionClient 12 | .schema(upsertPatientSchema) 13 | .action(async ({ parsedInput, ctx }) => { 14 | await db 15 | .insert(patientsTable) 16 | .values({ 17 | ...parsedInput, 18 | id: parsedInput.id, 19 | clinicId: ctx.user.clinic.id, 20 | }) 21 | .onConflictDoUpdate({ 22 | target: [patientsTable.id], 23 | set: { 24 | ...parsedInput, 25 | }, 26 | }); 27 | revalidatePath("/patients"); 28 | }); 29 | -------------------------------------------------------------------------------- /src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as ProgressPrimitive from "@radix-ui/react-progress" 4 | import * as React from "react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Progress({ 9 | className, 10 | value, 11 | ...props 12 | }: React.ComponentProps) { 13 | return ( 14 | 22 | 27 | 28 | ) 29 | } 30 | 31 | export { Progress } 32 | -------------------------------------------------------------------------------- /src/app/(protected)/clinic-form/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogContent, 4 | DialogDescription, 5 | DialogHeader, 6 | DialogTitle, 7 | } from "@/components/ui/dialog"; 8 | import WithAuthentication from "@/hocs/with-authentication"; 9 | 10 | import ClinicForm from "./_components/form"; 11 | 12 | const ClinicFormPage = async () => { 13 | return ( 14 | 15 |
16 | 17 | 18 | 19 | Adicionar clínica 20 | 21 | Adicione uma clínica para continuar. 22 | 23 | 24 | 25 | 26 | 27 |
28 |
29 | ); 30 | }; 31 | 32 | export default ClinicFormPage; 33 | -------------------------------------------------------------------------------- /src/lib/next-safe-action.ts: -------------------------------------------------------------------------------- 1 | import { headers } from "next/headers"; 2 | import { createSafeActionClient } from "next-safe-action"; 3 | 4 | import { auth } from "./auth"; 5 | 6 | export const actionClient = createSafeActionClient(); 7 | 8 | export const protectedActionClient = createSafeActionClient().use( 9 | async ({ next }) => { 10 | const session = await auth.api.getSession({ 11 | headers: await headers(), 12 | }); 13 | if (!session?.user) { 14 | throw new Error("Unauthorized"); 15 | } 16 | return next({ ctx: { user: session.user } }); 17 | }, 18 | ); 19 | 20 | export const protectedWithClinicActionClient = protectedActionClient.use( 21 | async ({ next, ctx }) => { 22 | if (!ctx.user.clinic?.id) { 23 | throw new Error("Clinic not found"); 24 | } 25 | return next({ 26 | ctx: { 27 | user: { 28 | ...ctx.user, 29 | clinic: ctx.user.clinic!, 30 | }, 31 | }, 32 | }); 33 | }, 34 | ); 35 | -------------------------------------------------------------------------------- /src/actions/delete-doctor/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { eq } from "drizzle-orm"; 4 | import { revalidatePath } from "next/cache"; 5 | import { z } from "zod"; 6 | 7 | import { db } from "@/db"; 8 | import { doctorsTable } from "@/db/schema"; 9 | import { protectedWithClinicActionClient } from "@/lib/next-safe-action"; 10 | 11 | export const deleteDoctor = protectedWithClinicActionClient 12 | .schema( 13 | z.object({ 14 | id: z.string().uuid(), 15 | }), 16 | ) 17 | .action(async ({ parsedInput, ctx }) => { 18 | const doctor = await db.query.doctorsTable.findFirst({ 19 | where: eq(doctorsTable.id, parsedInput.id), 20 | }); 21 | if (!doctor) { 22 | throw new Error("Médico não encontrado"); 23 | } 24 | if (doctor.clinicId !== ctx.user.clinic.id) { 25 | throw new Error("Médico não encontrado"); 26 | } 27 | await db.delete(doctorsTable).where(eq(doctorsTable.id, parsedInput.id)); 28 | revalidatePath("/doctors"); 29 | }); 30 | -------------------------------------------------------------------------------- /src/actions/delete-patient/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { eq } from "drizzle-orm"; 4 | import { revalidatePath } from "next/cache"; 5 | import { z } from "zod"; 6 | 7 | import { db } from "@/db"; 8 | import { patientsTable } from "@/db/schema"; 9 | import { protectedWithClinicActionClient } from "@/lib/next-safe-action"; 10 | 11 | export const deletePatient = protectedWithClinicActionClient 12 | .schema( 13 | z.object({ 14 | id: z.string().uuid(), 15 | }), 16 | ) 17 | .action(async ({ parsedInput, ctx }) => { 18 | const patient = await db.query.patientsTable.findFirst({ 19 | where: eq(patientsTable.id, parsedInput.id), 20 | }); 21 | if (!patient) { 22 | throw new Error("Paciente não encontrado"); 23 | } 24 | if (patient.clinicId !== ctx.user.clinic.id) { 25 | throw new Error("Paciente não encontrado"); 26 | } 27 | await db.delete(patientsTable).where(eq(patientsTable.id, parsedInput.id)); 28 | revalidatePath("/patients"); 29 | }); 30 | -------------------------------------------------------------------------------- /src/app/(protected)/doctors/_helpers/availability.ts: -------------------------------------------------------------------------------- 1 | import "dayjs/locale/pt-br"; 2 | 3 | import dayjs from "dayjs"; 4 | import utc from "dayjs/plugin/utc"; 5 | 6 | import { doctorsTable } from "@/db/schema"; 7 | 8 | dayjs.extend(utc); 9 | dayjs.locale("pt-br"); 10 | 11 | export const getAvailability = (doctor: typeof doctorsTable.$inferSelect) => { 12 | const from = dayjs() 13 | .utc() 14 | .day(doctor.availableFromWeekDay) 15 | .set("hour", Number(doctor.availableFromTime.split(":")[0])) 16 | .set("minute", Number(doctor.availableFromTime.split(":")[1])) 17 | .set("second", Number(doctor.availableFromTime.split(":")[2] || 0)) 18 | .local(); 19 | const to = dayjs() 20 | .utc() 21 | .day(doctor.availableToWeekDay) 22 | .set("hour", Number(doctor.availableToTime.split(":")[0])) 23 | .set("minute", Number(doctor.availableToTime.split(":")[1])) 24 | .set("second", Number(doctor.availableToTime.split(":")[2] || 0)) 25 | .local(); 26 | return { from, to }; 27 | }; 28 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | 3 | import type { Metadata } from "next"; 4 | import { Manrope } from "next/font/google"; 5 | import { NuqsAdapter } from "nuqs/adapters/next/app"; 6 | 7 | import { Toaster } from "@/components/ui/sonner"; 8 | import { ReactQueryProvider } from "@/providers/react-query"; 9 | 10 | const manrope = Manrope({ 11 | variable: "--font-manrope", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: "Create Next App", 17 | description: "Generated by create next app", 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode; 24 | }>) { 25 | return ( 26 | 27 | 28 | 29 | {children} 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/hocs/with-authentication.tsx: -------------------------------------------------------------------------------- 1 | // Higher ORder Component 2 | // É um componente que recebe um componente e o renderiza 3 | // mas antes de renderizá-lo, executa alguma ação 4 | // ou, passa alguma prop extra pra esse componente 5 | 6 | import { headers } from "next/headers"; 7 | import { redirect } from "next/navigation"; 8 | 9 | import { auth } from "@/lib/auth"; 10 | 11 | const WithAuthentication = async ({ 12 | children, 13 | mustHavePlan = false, 14 | mustHaveClinic = false, 15 | }: { 16 | children: React.ReactNode; 17 | mustHavePlan?: boolean; 18 | mustHaveClinic?: boolean; 19 | }) => { 20 | const session = await auth.api.getSession({ 21 | headers: await headers(), 22 | }); 23 | if (!session?.user) { 24 | redirect("/authentication"); 25 | } 26 | if (mustHavePlan && !session.user.plan) { 27 | redirect("/new-subscription"); 28 | } 29 | if (mustHaveClinic && !session.user.clinic) { 30 | redirect("/clinic-form"); 31 | } 32 | return children; 33 | }; 34 | 35 | export default WithAuthentication; 36 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /src/actions/delete-appointment/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { eq } from "drizzle-orm"; 4 | import { revalidatePath } from "next/cache"; 5 | import { z } from "zod"; 6 | 7 | import { db } from "@/db"; 8 | import { appointmentsTable } from "@/db/schema"; 9 | import { protectedWithClinicActionClient } from "@/lib/next-safe-action"; 10 | 11 | export const deleteAppointment = protectedWithClinicActionClient 12 | .schema( 13 | z.object({ 14 | id: z.string().uuid(), 15 | }), 16 | ) 17 | .action(async ({ parsedInput, ctx }) => { 18 | const appointment = await db.query.appointmentsTable.findFirst({ 19 | where: eq(appointmentsTable.id, parsedInput.id), 20 | }); 21 | if (!appointment) { 22 | throw new Error("Agendamento não encontrado"); 23 | } 24 | if (appointment.clinicId !== ctx.user.clinic.id) { 25 | throw new Error("Agendamento não encontrado"); 26 | } 27 | await db 28 | .delete(appointmentsTable) 29 | .where(eq(appointmentsTable.id, parsedInput.id)); 30 | revalidatePath("/appointments"); 31 | }); 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Roteiro Aula 01: Setup do Projeto 2 | 3 | - [x] Inicialização do projeto Next.js 4 | - [x] Configuração de ferramentas (ESlint, Prettier, Tailwind) 5 | - [x] Configuração do Drizzle e banco de dados 6 | - [x] Configuração do shadcn/ui 7 | 8 | ## Roteiro Aula 02: Autenticação e Configurações do Estabelecimento 9 | 10 | - [x] Tela de login e criação de conta 11 | - [x] Login com e-mail e senha 12 | - [x] Login com o Google 13 | - [x] Fundamentos do Next.js (Rotas, Páginas, Layouts) 14 | - [x] Criação de clínica 15 | 16 | ## Roteiro Aula 03: Gerenciamento de Profissionais e Disponibilidade 17 | 18 | - [x] Sidebar e Route Groups 19 | - [x] Página de médicos 20 | - [x] Criação de médicos & NextSafeAction 21 | - [x] Listagem de médicos 22 | - [x] Atualização de médicos 23 | - [x] Deleção de médicos 24 | 25 | ## Roteiro Aula 04: Gerenciamento de Pacientes e Agendamentos 26 | 27 | - [] Criação de pacientes 28 | - [] Edição de pacientes 29 | - [] Listagem de pacientes 30 | - [] Deleção de pacientes 31 | - [] Criação de agendamentos 32 | - [] Listagem de agendamentos 33 | - [] Deleção de agendamentos 34 | 35 | --- 36 | -------------------------------------------------------------------------------- /src/actions/create-stripe-checkout/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import Stripe from "stripe"; 4 | 5 | import { protectedActionClient } from "@/lib/next-safe-action"; 6 | 7 | export const createStripeCheckout = protectedActionClient.action( 8 | async ({ ctx }) => { 9 | if (!process.env.STRIPE_SECRET_KEY) { 10 | throw new Error("Stripe secret key not found"); 11 | } 12 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { 13 | apiVersion: "2025-05-28.basil", 14 | }); 15 | const { id: sessionId } = await stripe.checkout.sessions.create({ 16 | payment_method_types: ["card"], 17 | mode: "subscription", 18 | success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`, 19 | cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`, 20 | subscription_data: { 21 | metadata: { 22 | userId: ctx.user.id, 23 | }, 24 | }, 25 | line_items: [ 26 | { 27 | price: process.env.STRIPE_ESSENTIAL_PLAN_PRICE_ID, 28 | quantity: 1, 29 | }, 30 | ], 31 | }); 32 | return { 33 | sessionId, 34 | }; 35 | }, 36 | ); 37 | -------------------------------------------------------------------------------- /src/actions/upsert-doctor/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const upsertDoctorSchema = z 4 | .object({ 5 | id: z.string().uuid().optional(), 6 | name: z.string().trim().min(1, { 7 | message: "Nome é obrigatório.", 8 | }), 9 | specialty: z.string().trim().min(1, { 10 | message: "Especialidade é obrigatória.", 11 | }), 12 | appointmentPriceInCents: z.number().min(1, { 13 | message: "Preço da consulta é obrigatório.", 14 | }), 15 | availableFromWeekDay: z.number().min(0).max(6), 16 | availableToWeekDay: z.number().min(0).max(6), 17 | availableFromTime: z.string().min(1, { 18 | message: "Hora de início é obrigatória.", 19 | }), 20 | availableToTime: z.string().min(1, { 21 | message: "Hora de término é obrigatória.", 22 | }), 23 | }) 24 | .refine( 25 | (data) => { 26 | return data.availableFromTime < data.availableToTime; 27 | }, 28 | { 29 | message: 30 | "O horário de início não pode ser anterior ao horário de término.", 31 | path: ["availableToTime"], 32 | }, 33 | ); 34 | 35 | export type UpsertDoctorSchema = z.infer; 36 | -------------------------------------------------------------------------------- /src/app/authentication/page.tsx: -------------------------------------------------------------------------------- 1 | import { headers } from "next/headers"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 5 | import { auth } from "@/lib/auth"; 6 | 7 | import LoginForm from "./components/login-form"; 8 | import SignUpForm from "./components/sign-up-form"; 9 | 10 | const AuthenticationPage = async () => { 11 | const session = await auth.api.getSession({ 12 | headers: await headers(), 13 | }); 14 | if (session?.user) { 15 | redirect("/dashboard"); 16 | } 17 | return ( 18 |
19 | 20 | 21 | Login 22 | Criar conta 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 | ); 33 | }; 34 | 35 | export default AuthenticationPage; 36 | -------------------------------------------------------------------------------- /src/app/(protected)/appointments/_components/add-appointment-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Plus } from "lucide-react"; 4 | import { useState } from "react"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { Dialog, DialogTrigger } from "@/components/ui/dialog"; 8 | import { doctorsTable, patientsTable } from "@/db/schema"; 9 | 10 | import AddAppointmentForm from "./add-appointment-form"; 11 | 12 | interface AddAppointmentButtonProps { 13 | patients: (typeof patientsTable.$inferSelect)[]; 14 | doctors: (typeof doctorsTable.$inferSelect)[]; 15 | } 16 | 17 | const AddAppointmentButton = ({ 18 | patients, 19 | doctors, 20 | }: AddAppointmentButtonProps) => { 21 | const [isOpen, setIsOpen] = useState(false); 22 | 23 | return ( 24 | 25 | 26 | 30 | 31 | setIsOpen(false)} 36 | /> 37 | 38 | ); 39 | }; 40 | 41 | export default AddAppointmentButton; 42 | -------------------------------------------------------------------------------- /src/components/ui/page-container.tsx: -------------------------------------------------------------------------------- 1 | export const PageContainer = ({ children }: { children: React.ReactNode }) => { 2 | return
{children}
; 3 | }; 4 | 5 | export const PageHeader = ({ children }: { children: React.ReactNode }) => { 6 | return ( 7 |
{children}
8 | ); 9 | }; 10 | 11 | export const PageHeaderContent = ({ 12 | children, 13 | }: { 14 | children: React.ReactNode; 15 | }) => { 16 | return
{children}
; 17 | }; 18 | 19 | export const PageTitle = ({ children }: { children: React.ReactNode }) => { 20 | return

{children}

; 21 | }; 22 | 23 | export const PageDescription = ({ 24 | children, 25 | }: { 26 | children: React.ReactNode; 27 | }) => { 28 | return

{children}

; 29 | }; 30 | 31 | export const PageActions = ({ children }: { children: React.ReactNode }) => { 32 | return
{children}
; 33 | }; 34 | 35 | export const PageContent = ({ children }: { children: React.ReactNode }) => { 36 | return
{children}
; 37 | }; 38 | -------------------------------------------------------------------------------- /src/app/(protected)/subscription/page.tsx: -------------------------------------------------------------------------------- 1 | import { headers } from "next/headers"; 2 | 3 | import { 4 | PageContainer, 5 | PageContent, 6 | PageDescription, 7 | PageHeader, 8 | PageHeaderContent, 9 | PageTitle, 10 | } from "@/components/ui/page-container"; 11 | import WithAuthentication from "@/hocs/with-authentication"; 12 | import { auth } from "@/lib/auth"; 13 | 14 | import { SubscriptionPlan } from "./_components/subscription-plan"; 15 | 16 | const SubscriptionPage = async () => { 17 | const session = await auth.api.getSession({ 18 | headers: await headers(), 19 | }); 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | Assinatura 27 | Gerencie a sua assinatura. 28 | 29 | 30 | 31 | 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | export default SubscriptionPage; 43 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 4 | import * as React from "react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Avatar({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | function AvatarImage({ 25 | className, 26 | ...props 27 | }: React.ComponentProps) { 28 | return ( 29 | 34 | ) 35 | } 36 | 37 | function AvatarFallback({ 38 | className, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 50 | ) 51 | } 52 | 53 | export { Avatar, AvatarFallback,AvatarImage } 54 | -------------------------------------------------------------------------------- /src/app/(protected)/patients/_components/table-columns.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ColumnDef } from "@tanstack/react-table"; 4 | 5 | import { patientsTable } from "@/db/schema"; 6 | 7 | import PatientsTableActions from "./table-actions"; 8 | 9 | type Patient = typeof patientsTable.$inferSelect; 10 | 11 | export const patientsTableColumns: ColumnDef[] = [ 12 | { 13 | id: "name", 14 | accessorKey: "name", 15 | header: "Nome", 16 | }, 17 | { 18 | id: "email", 19 | accessorKey: "email", 20 | header: "Email", 21 | }, 22 | { 23 | id: "phoneNumber", 24 | accessorKey: "phoneNumber", 25 | header: "Telefone", 26 | cell: (params) => { 27 | const patient = params.row.original; 28 | const phoneNumber = patient.phoneNumber; 29 | if (!phoneNumber) return ""; 30 | const formatted = phoneNumber.replace( 31 | /(\d{2})(\d{5})(\d{4})/, 32 | "($1) $2-$3", 33 | ); 34 | return formatted; 35 | }, 36 | }, 37 | { 38 | id: "sex", 39 | accessorKey: "sex", 40 | header: "Sexo", 41 | cell: (params) => { 42 | const patient = params.row.original; 43 | return patient.sex === "male" ? "Masculino" : "Feminino"; 44 | }, 45 | }, 46 | { 47 | id: "actions", 48 | cell: (params) => { 49 | const patient = params.row.original; 50 | return ; 51 | }, 52 | }, 53 | ]; 54 | -------------------------------------------------------------------------------- /src/actions/add-appointment/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import dayjs from "dayjs"; 4 | import { revalidatePath } from "next/cache"; 5 | 6 | import { db } from "@/db"; 7 | import { appointmentsTable } from "@/db/schema"; 8 | import { protectedWithClinicActionClient } from "@/lib/next-safe-action"; 9 | 10 | import { getAvailableTimes } from "../get-available-times"; 11 | import { addAppointmentSchema } from "./schema"; 12 | 13 | export const addAppointment = protectedWithClinicActionClient 14 | .schema(addAppointmentSchema) 15 | .action(async ({ parsedInput, ctx }) => { 16 | const availableTimes = await getAvailableTimes({ 17 | doctorId: parsedInput.doctorId, 18 | date: dayjs(parsedInput.date).format("YYYY-MM-DD"), 19 | }); 20 | if (!availableTimes?.data) { 21 | throw new Error("No available times"); 22 | } 23 | const isTimeAvailable = availableTimes.data?.some( 24 | (time) => time.value === parsedInput.time && time.available, 25 | ); 26 | if (!isTimeAvailable) { 27 | throw new Error("Time not available"); 28 | } 29 | const appointmentDateTime = dayjs(parsedInput.date) 30 | .set("hour", parseInt(parsedInput.time.split(":")[0])) 31 | .set("minute", parseInt(parsedInput.time.split(":")[1])) 32 | .toDate(); 33 | 34 | await db.insert(appointmentsTable).values({ 35 | ...parsedInput, 36 | clinicId: ctx.user.clinic.id, 37 | date: appointmentDateTime, 38 | }); 39 | 40 | revalidatePath("/appointments"); 41 | revalidatePath("/dashboard"); 42 | }); 43 | -------------------------------------------------------------------------------- /src/app/(protected)/patients/page.tsx: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { headers } from "next/headers"; 3 | 4 | import { DataTable } from "@/components/ui/data-table"; 5 | import { 6 | PageActions, 7 | PageContainer, 8 | PageContent, 9 | PageDescription, 10 | PageHeader, 11 | PageHeaderContent, 12 | PageTitle, 13 | } from "@/components/ui/page-container"; 14 | import { db } from "@/db"; 15 | import { patientsTable } from "@/db/schema"; 16 | import WithAuthentication from "@/hocs/with-authentication"; 17 | import { auth } from "@/lib/auth"; 18 | 19 | import AddPatientButton from "./_components/add-patient-button"; 20 | import { patientsTableColumns } from "./_components/table-columns"; 21 | 22 | const PatientsPage = async () => { 23 | const session = await auth.api.getSession({ 24 | headers: await headers(), 25 | }); 26 | const patients = await db.query.patientsTable.findMany({ 27 | where: eq(patientsTable.clinicId, session!.user.clinic!.id), 28 | }); 29 | return ( 30 | 31 | 32 | 33 | 34 | Pacientes 35 | 36 | Gerencie os pacientes da sua clínica 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default PatientsPage; 52 | -------------------------------------------------------------------------------- /src/app/(protected)/doctors/page.tsx: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { headers } from "next/headers"; 3 | 4 | import { 5 | PageActions, 6 | PageContainer, 7 | PageContent, 8 | PageDescription, 9 | PageHeader, 10 | PageHeaderContent, 11 | PageTitle, 12 | } from "@/components/ui/page-container"; 13 | import { db } from "@/db"; 14 | import { doctorsTable } from "@/db/schema"; 15 | import WithAuthentication from "@/hocs/with-authentication"; 16 | import { auth } from "@/lib/auth"; 17 | 18 | import AddDoctorButton from "./_components/add-doctor-button"; 19 | import DoctorCard from "./_components/doctor-card"; 20 | 21 | const DoctorsPage = async () => { 22 | const session = await auth.api.getSession({ 23 | headers: await headers(), 24 | }); 25 | const doctors = await db.query.doctorsTable.findMany({ 26 | where: eq(doctorsTable.clinicId, session!.user.clinic!.id), 27 | }); 28 | return ( 29 | 30 | 31 | 32 | 33 | Médicos 34 | 35 | Gerencie os médicos da sua clínica 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
44 | {doctors.map((doctor) => ( 45 | 46 | ))} 47 |
48 |
49 |
50 |
51 | ); 52 | }; 53 | 54 | export default DoctorsPage; 55 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as PopoverPrimitive from "@radix-ui/react-popover" 4 | import * as React from "react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Popover({ 9 | ...props 10 | }: React.ComponentProps) { 11 | return 12 | } 13 | 14 | function PopoverTrigger({ 15 | ...props 16 | }: React.ComponentProps) { 17 | return 18 | } 19 | 20 | function PopoverContent({ 21 | className, 22 | align = "center", 23 | sideOffset = 4, 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 28 | 38 | 39 | ) 40 | } 41 | 42 | function PopoverAnchor({ 43 | ...props 44 | }: React.ComponentProps) { 45 | return 46 | } 47 | 48 | export { Popover, PopoverAnchor,PopoverContent, PopoverTrigger } 49 | -------------------------------------------------------------------------------- /src/actions/upsert-doctor/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import dayjs from "dayjs"; 4 | import utc from "dayjs/plugin/utc"; 5 | import { revalidatePath } from "next/cache"; 6 | 7 | import { db } from "@/db"; 8 | import { doctorsTable } from "@/db/schema"; 9 | import { protectedWithClinicActionClient } from "@/lib/next-safe-action"; 10 | 11 | import { upsertDoctorSchema } from "./schema"; 12 | 13 | dayjs.extend(utc); 14 | 15 | export const upsertDoctor = protectedWithClinicActionClient 16 | .schema(upsertDoctorSchema) 17 | .action(async ({ parsedInput, ctx }) => { 18 | const availableFromTime = parsedInput.availableFromTime; // 15:30:00 19 | const availableToTime = parsedInput.availableToTime; // 16:00:00 20 | 21 | const availableFromTimeUTC = dayjs() 22 | .set("hour", parseInt(availableFromTime.split(":")[0])) 23 | .set("minute", parseInt(availableFromTime.split(":")[1])) 24 | .set("second", parseInt(availableFromTime.split(":")[2])) 25 | .utc(); 26 | const availableToTimeUTC = dayjs() 27 | .set("hour", parseInt(availableToTime.split(":")[0])) 28 | .set("minute", parseInt(availableToTime.split(":")[1])) 29 | .set("second", parseInt(availableToTime.split(":")[2])) 30 | .utc(); 31 | 32 | await db 33 | .insert(doctorsTable) 34 | .values({ 35 | ...parsedInput, 36 | id: parsedInput.id, 37 | clinicId: ctx.user.clinic.id, 38 | availableFromTime: availableFromTimeUTC.format("HH:mm:ss"), 39 | availableToTime: availableToTimeUTC.format("HH:mm:ss"), 40 | }) 41 | .onConflictDoUpdate({ 42 | target: [doctorsTable.id], 43 | set: { 44 | ...parsedInput, 45 | availableFromTime: availableFromTimeUTC.format("HH:mm:ss"), 46 | availableToTime: availableToTimeUTC.format("HH:mm:ss"), 47 | }, 48 | }); 49 | revalidatePath("/doctors"); 50 | }); 51 | -------------------------------------------------------------------------------- /src/app/new-subscription/page.tsx: -------------------------------------------------------------------------------- 1 | import { headers } from "next/headers"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import { auth } from "@/lib/auth"; 5 | 6 | import { SubscriptionPlan } from "../(protected)/subscription/_components/subscription-plan"; 7 | 8 | export default async function Home() { 9 | const session = await auth.api.getSession({ 10 | headers: await headers(), 11 | }); 12 | if (!session) { 13 | redirect("/login"); 14 | } 15 | return ( 16 |
17 |
18 |

19 | Desbloqueie todo o potencial da sua clínica 20 |

21 |

22 | Para continuar utilizando nossa plataforma e transformar a gestão do 23 | seu consultório, é necessário escolher um plano que se adapte às suas 24 | necessidades. 25 |

26 |
27 |

28 | 🚀{" "} 29 | 30 | Profissionais que utilizam nossa plataforma economizam em média 15 31 | horas por semana 32 | {" "} 33 | em tarefas administrativas. Não perca mais tempo com agendas manuais 34 | e processos ineficientes! 35 |

36 |
37 |
38 | 39 |
40 | 41 |
42 | 43 |
44 |

45 | Junte-se a mais de 2.000 profissionais de saúde que já transformaram 46 | sua rotina com nossa solução. Garantia de satisfação de 30 dias ou seu 47 | dinheiro de volta. 48 |

49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/app/(protected)/dashboard/_components/stats-cards.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CalendarIcon, 3 | DollarSignIcon, 4 | UserIcon, 5 | UsersIcon, 6 | } from "lucide-react"; 7 | 8 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 9 | import { formatCurrencyInCents } from "@/helpers/currency"; 10 | 11 | interface StatsCardsProps { 12 | totalRevenue: number | null; 13 | totalAppointments: number; 14 | totalPatients: number; 15 | totalDoctors: number; 16 | } 17 | 18 | const StatsCards = ({ 19 | totalRevenue, 20 | totalAppointments, 21 | totalPatients, 22 | totalDoctors, 23 | }: StatsCardsProps) => { 24 | const stats = [ 25 | { 26 | title: "Faturamento", 27 | value: totalRevenue ? formatCurrencyInCents(totalRevenue) : "R$ 0,00", 28 | icon: DollarSignIcon, 29 | }, 30 | { 31 | title: "Agendamentos", 32 | value: totalAppointments.toString(), 33 | icon: CalendarIcon, 34 | }, 35 | { 36 | title: "Pacientes", 37 | value: totalPatients.toString(), 38 | icon: UserIcon, 39 | }, 40 | { 41 | title: "Médicos", 42 | value: totalDoctors.toString(), 43 | icon: UsersIcon, 44 | }, 45 | ]; 46 | 47 | return ( 48 |
49 | {stats.map((stat) => { 50 | const Icon = stat.icon; 51 | return ( 52 | 53 | 54 |
55 | 56 |
57 | 58 | {stat.title} 59 | 60 |
61 | 62 |
{stat.value}
63 |
64 |
65 | ); 66 | })} 67 |
68 | ); 69 | }; 70 | 71 | export default StatsCards; 72 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 4 | import * as React from "react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function TooltipProvider({ 9 | delayDuration = 0, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 18 | ) 19 | } 20 | 21 | function Tooltip({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | function TooltipTrigger({ 32 | ...props 33 | }: React.ComponentProps) { 34 | return 35 | } 36 | 37 | function TooltipContent({ 38 | className, 39 | sideOffset = 0, 40 | children, 41 | ...props 42 | }: React.ComponentProps) { 43 | return ( 44 | 45 | 54 | {children} 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | export { Tooltip, TooltipContent, TooltipProvider,TooltipTrigger } 62 | -------------------------------------------------------------------------------- /src/app/(protected)/appointments/_components/table-columns.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ColumnDef } from "@tanstack/react-table"; 4 | import { format } from "date-fns"; 5 | import { ptBR } from "date-fns/locale"; 6 | 7 | import { appointmentsTable } from "@/db/schema"; 8 | 9 | import AppointmentsTableActions from "./table-actions"; 10 | 11 | type AppointmentWithRelations = typeof appointmentsTable.$inferSelect & { 12 | patient: { 13 | id: string; 14 | name: string; 15 | email: string; 16 | phoneNumber: string; 17 | sex: "male" | "female"; 18 | }; 19 | doctor: { 20 | id: string; 21 | name: string; 22 | specialty: string; 23 | }; 24 | }; 25 | 26 | export const appointmentsTableColumns: ColumnDef[] = [ 27 | { 28 | id: "patient", 29 | accessorKey: "patient.name", 30 | header: "Paciente", 31 | }, 32 | { 33 | id: "doctor", 34 | accessorKey: "doctor.name", 35 | header: "Médico", 36 | cell: (params) => { 37 | const appointment = params.row.original; 38 | return `${appointment.doctor.name}`; 39 | }, 40 | }, 41 | { 42 | id: "date", 43 | accessorKey: "date", 44 | header: "Data e Hora", 45 | cell: (params) => { 46 | const appointment = params.row.original; 47 | return format(new Date(appointment.date), "dd/MM/yyyy 'às' HH:mm", { 48 | locale: ptBR, 49 | }); 50 | }, 51 | }, 52 | { 53 | id: "specialty", 54 | accessorKey: "doctor.specialty", 55 | header: "Especialidade", 56 | }, 57 | { 58 | id: "price", 59 | accessorKey: "appointmentPriceInCents", 60 | header: "Valor", 61 | cell: (params) => { 62 | const appointment = params.row.original; 63 | const price = appointment.appointmentPriceInCents / 100; 64 | return new Intl.NumberFormat("pt-BR", { 65 | style: "currency", 66 | currency: "BRL", 67 | }).format(price); 68 | }, 69 | }, 70 | { 71 | id: "actions", 72 | cell: (params) => { 73 | const appointment = params.row.original; 74 | return ; 75 | }, 76 | }, 77 | ]; 78 | -------------------------------------------------------------------------------- /src/app/(protected)/dashboard/_components/top-doctors.tsx: -------------------------------------------------------------------------------- 1 | import { Stethoscope } from "lucide-react"; 2 | 3 | import { Avatar, AvatarFallback } from "@/components/ui/avatar"; 4 | import { Card, CardContent, CardTitle } from "@/components/ui/card"; 5 | 6 | interface TopDoctorsProps { 7 | doctors: { 8 | id: string; 9 | name: string; 10 | avatarImageUrl: string | null; 11 | specialty: string; 12 | appointments: number; 13 | }[]; 14 | } 15 | 16 | export default function TopDoctors({ doctors }: TopDoctorsProps) { 17 | return ( 18 | 19 | 20 |
21 |
22 | 23 | Médicos 24 |
25 |
26 | 27 | {/* Doctors List */} 28 |
29 | {doctors.map((doctor) => ( 30 |
31 |
32 | 33 | 34 | {doctor.name 35 | .split(" ") 36 | .map((n) => n[0]) 37 | .join("") 38 | .slice(0, 2)} 39 | 40 | 41 |
42 |

{doctor.name}

43 |

44 | {doctor.specialty} 45 |

46 |
47 |
48 |
49 | 50 | {doctor.appointments} agend. 51 | 52 |
53 |
54 | ))} 55 |
56 |
57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/app/(protected)/appointments/page.tsx: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { headers } from "next/headers"; 3 | 4 | import { DataTable } from "@/components/ui/data-table"; 5 | import { 6 | PageActions, 7 | PageContainer, 8 | PageContent, 9 | PageDescription, 10 | PageHeader, 11 | PageHeaderContent, 12 | PageTitle, 13 | } from "@/components/ui/page-container"; 14 | import { db } from "@/db"; 15 | import { appointmentsTable, doctorsTable, patientsTable } from "@/db/schema"; 16 | import WithAuthentication from "@/hocs/with-authentication"; 17 | import { auth } from "@/lib/auth"; 18 | 19 | import AddAppointmentButton from "./_components/add-appointment-button"; 20 | import { appointmentsTableColumns } from "./_components/table-columns"; 21 | 22 | const AppointmentsPage = async () => { 23 | const session = await auth.api.getSession({ 24 | headers: await headers(), 25 | }); 26 | const [patients, doctors, appointments] = await Promise.all([ 27 | db.query.patientsTable.findMany({ 28 | where: eq(patientsTable.clinicId, session!.user.clinic!.id), 29 | }), 30 | db.query.doctorsTable.findMany({ 31 | where: eq(doctorsTable.clinicId, session!.user.clinic!.id), 32 | }), 33 | db.query.appointmentsTable.findMany({ 34 | where: eq(appointmentsTable.clinicId, session!.user.clinic!.id), 35 | with: { 36 | patient: true, 37 | doctor: true, 38 | }, 39 | }), 40 | ]); 41 | 42 | return ( 43 | 44 | 45 | 46 | 47 | Agendamentos 48 | 49 | Gerencie os agendamentos da sua clínica 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | }; 63 | 64 | export default AppointmentsPage; 65 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as TabsPrimitive from "@radix-ui/react-tabs" 4 | import * as React from "react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Tabs({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 18 | ) 19 | } 20 | 21 | function TabsList({ 22 | className, 23 | ...props 24 | }: React.ComponentProps) { 25 | return ( 26 | 34 | ) 35 | } 36 | 37 | function TabsTrigger({ 38 | className, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 50 | ) 51 | } 52 | 53 | function TabsContent({ 54 | className, 55 | ...props 56 | }: React.ComponentProps) { 57 | return ( 58 | 63 | ) 64 | } 65 | 66 | export { Tabs, TabsContent,TabsList, TabsTrigger } 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "doutor-agenda", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@hookform/resolvers": "^5.0.1", 13 | "@radix-ui/react-alert-dialog": "^1.1.14", 14 | "@radix-ui/react-avatar": "^1.1.10", 15 | "@radix-ui/react-dialog": "^1.1.14", 16 | "@radix-ui/react-dropdown-menu": "^2.1.15", 17 | "@radix-ui/react-label": "^2.1.7", 18 | "@radix-ui/react-popover": "^1.1.14", 19 | "@radix-ui/react-progress": "^1.1.7", 20 | "@radix-ui/react-select": "^2.2.5", 21 | "@radix-ui/react-separator": "^1.1.7", 22 | "@radix-ui/react-slot": "^1.2.3", 23 | "@radix-ui/react-tabs": "^1.1.12", 24 | "@radix-ui/react-tooltip": "^1.2.7", 25 | "@stripe/stripe-js": "^7.3.1", 26 | "@tanstack/react-query": "^5.76.2", 27 | "@tanstack/react-table": "^8.21.3", 28 | "better-auth": "^1.2.8", 29 | "class-variance-authority": "^0.7.1", 30 | "clsx": "^2.1.1", 31 | "date-fns": "^3.6.0", 32 | "dayjs": "^1.11.13", 33 | "dotenv": "^16.5.0", 34 | "drizzle-orm": "^0.43.1", 35 | "lucide-react": "^0.511.0", 36 | "next": "15.3.2", 37 | "next-safe-action": "^7.10.8", 38 | "next-themes": "^0.4.6", 39 | "nuqs": "^2.4.3", 40 | "pg": "^8.16.0", 41 | "react": "^19.1.0", 42 | "react-day-picker": "^8.10.1", 43 | "react-dom": "^19.1.0", 44 | "react-hook-form": "^7.56.4", 45 | "react-number-format": "^5.4.4", 46 | "recharts": "^2.15.3", 47 | "sonner": "^2.0.3", 48 | "stripe": "^18.2.0", 49 | "tailwind-merge": "^3.3.0", 50 | "zod": "^3.25.30" 51 | }, 52 | "devDependencies": { 53 | "@eslint/eslintrc": "^3", 54 | "@tailwindcss/postcss": "^4", 55 | "@types/node": "^22", 56 | "@types/react": "^19", 57 | "@types/react-dom": "^19", 58 | "drizzle-kit": "^0.31.1", 59 | "eslint": "^9", 60 | "eslint-config-next": "15.3.2", 61 | "eslint-plugin-simple-import-sort": "^12.1.1", 62 | "prettier": "^3.5.3", 63 | "prettier-plugin-tailwindcss": "^0.6.11", 64 | "tailwindcss": "^4", 65 | "tw-animate-css": "^1.3.0", 66 | "typescript": "^5" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /src/components/ui/data-table.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | ColumnDef, 5 | flexRender, 6 | getCoreRowModel, 7 | useReactTable, 8 | } from "@tanstack/react-table"; 9 | 10 | import { 11 | Table, 12 | TableBody, 13 | TableCell, 14 | TableHead, 15 | TableHeader, 16 | TableRow, 17 | } from "@/components/ui/table"; 18 | 19 | interface DataTableProps { 20 | columns: ColumnDef[]; 21 | data: TData[]; 22 | } 23 | 24 | export function DataTable({ 25 | columns, 26 | data, 27 | }: DataTableProps) { 28 | const table = useReactTable({ 29 | data, 30 | columns, 31 | getCoreRowModel: getCoreRowModel(), 32 | }); 33 | 34 | return ( 35 |
36 | 37 | 38 | {table.getHeaderGroups().map((headerGroup) => ( 39 | 40 | {headerGroup.headers.map((header) => { 41 | return ( 42 | 43 | {header.isPlaceholder 44 | ? null 45 | : flexRender( 46 | header.column.columnDef.header, 47 | header.getContext(), 48 | )} 49 | 50 | ); 51 | })} 52 | 53 | ))} 54 | 55 | 56 | {table.getRowModel().rows?.length ? ( 57 | table.getRowModel().rows.map((row) => ( 58 | 62 | {row.getVisibleCells().map((cell) => ( 63 | 64 | {flexRender(cell.column.columnDef.cell, cell.getContext())} 65 | 66 | ))} 67 | 68 | )) 69 | ) : ( 70 | 71 | 72 | Nenhum resultado encontrado. 73 | 74 | 75 | )} 76 | 77 |
78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardAction, 87 | CardContent, 88 | CardDescription, 89 | CardFooter, 90 | CardHeader, 91 | CardTitle, 92 | } 93 | -------------------------------------------------------------------------------- /src/app/(protected)/clinic-form/_components/form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { Loader2 } from "lucide-react"; 5 | import { isRedirectError } from "next/dist/client/components/redirect-error"; 6 | import { useForm } from "react-hook-form"; 7 | import { toast } from "sonner"; 8 | import { z } from "zod"; 9 | 10 | import { createClinic } from "@/actions/create-clinic"; 11 | import { Button } from "@/components/ui/button"; 12 | import { DialogFooter } from "@/components/ui/dialog"; 13 | import { 14 | Form, 15 | FormControl, 16 | FormField, 17 | FormItem, 18 | FormLabel, 19 | FormMessage, 20 | } from "@/components/ui/form"; 21 | import { Input } from "@/components/ui/input"; 22 | 23 | const clinicFormSchema = z.object({ 24 | name: z.string().trim().min(1, { message: "Nome é obrigatório" }), 25 | }); 26 | 27 | const ClinicForm = () => { 28 | const form = useForm>({ 29 | resolver: zodResolver(clinicFormSchema), 30 | defaultValues: { 31 | name: "", 32 | }, 33 | }); 34 | 35 | const onSubmit = async (data: z.infer) => { 36 | try { 37 | await createClinic(data.name); 38 | } catch (error) { 39 | if (isRedirectError(error)) { 40 | return; 41 | } 42 | console.error(error); 43 | toast.error("Erro ao criar clínica."); 44 | } 45 | }; 46 | 47 | return ( 48 | <> 49 |
50 | 51 | ( 55 | 56 | Nome 57 | 58 | 59 | 60 | 61 | 62 | )} 63 | /> 64 | 65 | 66 | 72 | 73 | 74 | 75 | 76 | ); 77 | }; 78 | 79 | export default ClinicForm; 80 | -------------------------------------------------------------------------------- /src/app/(protected)/doctors/_constants/index.ts: -------------------------------------------------------------------------------- 1 | export enum MedicalSpecialty { 2 | ALERGOLOGIA = "Alergologia", 3 | ANESTESIOLOGIA = "Anestesiologia", 4 | ANGIOLOGIA = "Angiologia", 5 | CANCEROLOGIA = "Cancerologia", 6 | CARDIOLOGIA = "Cardiologia", 7 | CIRURGIA_CARDIOVASCULAR = "Cirurgia Cardiovascular", 8 | CIRURGIA_CABECA_PESCOCO = "Cirurgia de Cabeça e Pescoço", 9 | CIRURGIA_DIGESTIVA = "Cirurgia do Aparelho Digestivo", 10 | CIRURGIA_GERAL = "Cirurgia Geral", 11 | CIRURGIA_PEDIATRICA = "Cirurgia Pediátrica", 12 | CIRURGIA_PLASTICA = "Cirurgia Plástica", 13 | CIRURGIA_TORACICA = "Cirurgia Torácica", 14 | CIRURGIA_VASCULAR = "Cirurgia Vascular", 15 | CLINICA_MEDICA = "Clínica Médica", 16 | DERMATOLOGIA = "Dermatologia", 17 | ENDOCRINOLOGIA = "Endocrinologia e Metabologia", 18 | ENDOSCOPIA = "Endoscopia", 19 | GASTROENTEROLOGIA = "Gastroenterologia", 20 | GERIATRIA = "Geriatria", 21 | GINECOLOGIA_OBSTETRICIA = "Ginecologia e Obstetrícia", 22 | HEMATOLOGIA = "Hematologia e Hemoterapia", 23 | HEPATOLOGIA = "Hepatologia", 24 | HOMEOPATIA = "Homeopatia", 25 | INFECTOLOGIA = "Infectologia", 26 | MASTOLOGIA = "Mastologia", 27 | MEDICINA_DE_EMERGENCIA = "Medicina de Emergência", 28 | MEDICINA_DO_ESPORTO = "Medicina do Esporte", 29 | MEDICINA_DO_TRABALHO = "Medicina do Trabalho", 30 | MEDICINA_DE_FAMILIA = "Medicina de Família e Comunidade", 31 | MEDICINA_FISICA_REABILITACAO = "Medicina Física e Reabilitação", 32 | MEDICINA_INTENSIVA = "Medicina Intensiva", 33 | MEDICINA_LEGAL = "Medicina Legal e Perícia Médica", 34 | NEFROLOGIA = "Nefrologia", 35 | NEUROCIRURGIA = "Neurocirurgia", 36 | NEUROLOGIA = "Neurologia", 37 | NUTROLOGIA = "Nutrologia", 38 | OFTALMOLOGIA = "Oftalmologia", 39 | ONCOLOGIA_CLINICA = "Oncologia Clínica", 40 | ORTOPEDIA_TRAUMATOLOGIA = "Ortopedia e Traumatologia", 41 | OTORRINOLARINGOLOGIA = "Otorrinolaringologia", 42 | PATOLOGIA = "Patologia", 43 | PATOLOGIA_CLINICA = "Patologia Clínica/Medicina Laboratorial", 44 | PEDIATRIA = "Pediatria", 45 | PNEUMOLOGIA = "Pneumologia", 46 | PSIQUIATRIA = "Psiquiatria", 47 | RADIOLOGIA = "Radiologia e Diagnóstico por Imagem", 48 | RADIOTERAPIA = "Radioterapia", 49 | REUMATOLOGIA = "Reumatologia", 50 | UROLOGIA = "Urologia", 51 | } 52 | 53 | export const medicalSpecialties = Object.entries(MedicalSpecialty).map( 54 | ([key, value]) => ({ 55 | value: MedicalSpecialty[key as keyof typeof MedicalSpecialty], 56 | label: value, 57 | }), 58 | ); 59 | -------------------------------------------------------------------------------- /src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth } from "better-auth"; 2 | import { drizzleAdapter } from "better-auth/adapters/drizzle"; 3 | import { customSession } from "better-auth/plugins"; 4 | import { eq } from "drizzle-orm"; 5 | 6 | import { db } from "@/db"; 7 | import * as schema from "@/db/schema"; 8 | import { usersTable, usersToClinicsTable } from "@/db/schema"; 9 | 10 | const FIVE_MINUTES = 5 * 60; 11 | 12 | export const auth = betterAuth({ 13 | database: drizzleAdapter(db, { 14 | provider: "pg", 15 | usePlural: true, 16 | schema, 17 | }), 18 | socialProviders: { 19 | google: { 20 | clientId: process.env.GOOGLE_CLIENT_ID as string, 21 | clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, 22 | }, 23 | }, 24 | plugins: [ 25 | customSession(async ({ user, session }) => { 26 | // TODO: colocar cache 27 | const [userData, clinics] = await Promise.all([ 28 | db.query.usersTable.findFirst({ 29 | where: eq(usersTable.id, user.id), 30 | }), 31 | db.query.usersToClinicsTable.findMany({ 32 | where: eq(usersToClinicsTable.userId, user.id), 33 | with: { 34 | clinic: true, 35 | user: true, 36 | }, 37 | }), 38 | ]); 39 | // TODO: Ao adaptar para o usuário ter múltiplas clínicas, deve-se mudar esse código 40 | const clinic = clinics?.[0]; 41 | return { 42 | user: { 43 | ...user, 44 | plan: userData?.plan, 45 | clinic: clinic?.clinicId 46 | ? { 47 | id: clinic?.clinicId, 48 | name: clinic?.clinic?.name, 49 | } 50 | : undefined, 51 | }, 52 | session, 53 | }; 54 | }), 55 | ], 56 | user: { 57 | modelName: "usersTable", 58 | additionalFields: { 59 | stripeCustomerId: { 60 | type: "string", 61 | fieldName: "stripeCustomerId", 62 | required: false, 63 | }, 64 | stripeSubscriptionId: { 65 | type: "string", 66 | fieldName: "stripeSubscriptionId", 67 | required: false, 68 | }, 69 | plan: { 70 | type: "string", 71 | fieldName: "plan", 72 | required: false, 73 | }, 74 | }, 75 | }, 76 | session: { 77 | cookieCache: { 78 | enabled: true, 79 | maxAge: FIVE_MINUTES, 80 | }, 81 | modelName: "sessionsTable", 82 | }, 83 | account: { 84 | modelName: "accountsTable", 85 | }, 86 | verification: { 87 | modelName: "verificationsTable", 88 | }, 89 | emailAndPassword: { 90 | enabled: true, 91 | }, 92 | }); 93 | -------------------------------------------------------------------------------- /src/app/(protected)/dashboard/_components/date-picker.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { addMonths, format } from "date-fns"; 4 | import { ptBR } from "date-fns/locale"; 5 | import { Calendar as CalendarIcon } from "lucide-react"; 6 | import { parseAsIsoDate, useQueryState } from "nuqs"; 7 | import * as React from "react"; 8 | import { DateRange } from "react-day-picker"; 9 | 10 | import { Button } from "@/components/ui/button"; 11 | import { Calendar } from "@/components/ui/calendar"; 12 | import { 13 | Popover, 14 | PopoverContent, 15 | PopoverTrigger, 16 | } from "@/components/ui/popover"; 17 | import { cn } from "@/lib/utils"; 18 | 19 | export function DatePicker({ 20 | className, 21 | }: React.HTMLAttributes) { 22 | const [from, setFrom] = useQueryState( 23 | "from", 24 | parseAsIsoDate.withDefault(new Date()), 25 | ); 26 | const [to, setTo] = useQueryState( 27 | "to", 28 | parseAsIsoDate.withDefault(addMonths(new Date(), 1)), 29 | ); 30 | const handleDateSelect = (dateRange: DateRange | undefined) => { 31 | if (dateRange?.from) { 32 | setFrom(dateRange.from, { 33 | shallow: false, 34 | }); 35 | } 36 | if (dateRange?.to) { 37 | setTo(dateRange.to, { 38 | shallow: false, 39 | }); 40 | } 41 | }; 42 | const date = { 43 | from, 44 | to, 45 | }; 46 | return ( 47 |
48 | 49 | 50 | 77 | 78 | 79 | 88 | 89 | 90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/app/api/stripe/webhook/route.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { NextResponse } from "next/server"; 3 | import Stripe from "stripe"; 4 | 5 | import { db } from "@/db"; 6 | import { usersTable } from "@/db/schema"; 7 | 8 | export const POST = async (request: Request) => { 9 | if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET) { 10 | throw new Error("Stripe secret key not found"); 11 | } 12 | const signature = request.headers.get("stripe-signature"); 13 | if (!signature) { 14 | throw new Error("Stripe signature not found"); 15 | } 16 | const text = await request.text(); 17 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { 18 | apiVersion: "2025-05-28.basil", 19 | }); 20 | const event = stripe.webhooks.constructEvent( 21 | text, 22 | signature, 23 | process.env.STRIPE_WEBHOOK_SECRET, 24 | ); 25 | 26 | switch (event.type) { 27 | case "invoice.paid": { 28 | if (!event.data.object.id) { 29 | throw new Error("Subscription ID not found"); 30 | } 31 | const { customer } = event.data.object as unknown as { 32 | customer: string; 33 | }; 34 | const { subscription_details } = event.data.object.parent as unknown as { 35 | subscription_details: { 36 | subscription: string; 37 | metadata: { 38 | userId: string; 39 | }; 40 | }; 41 | }; 42 | const subscription = subscription_details.subscription; 43 | if (!subscription) { 44 | throw new Error("Subscription not found"); 45 | } 46 | const userId = subscription_details.metadata.userId; 47 | if (!userId) { 48 | throw new Error("User ID not found"); 49 | } 50 | await db 51 | .update(usersTable) 52 | .set({ 53 | stripeSubscriptionId: subscription, 54 | stripeCustomerId: customer, 55 | plan: "essential", 56 | }) 57 | .where(eq(usersTable.id, userId)); 58 | break; 59 | } 60 | case "customer.subscription.deleted": { 61 | if (!event.data.object.id) { 62 | throw new Error("Subscription ID not found"); 63 | } 64 | const subscription = await stripe.subscriptions.retrieve( 65 | event.data.object.id, 66 | ); 67 | if (!subscription) { 68 | throw new Error("Subscription not found"); 69 | } 70 | const userId = subscription.metadata.userId; 71 | if (!userId) { 72 | throw new Error("User ID not found"); 73 | } 74 | await db 75 | .update(usersTable) 76 | .set({ 77 | stripeSubscriptionId: null, 78 | stripeCustomerId: null, 79 | plan: null, 80 | }) 81 | .where(eq(usersTable.id, userId)); 82 | } 83 | } 84 | return NextResponse.json({ 85 | received: true, 86 | }); 87 | }; 88 | -------------------------------------------------------------------------------- /.cursor/rules/general.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | Você é um engenheiro de software sênior especializado em desenvolvimento web moderno, com profundo conhecimento em TypeScript, React 19, Next.js 15 (App Router), Postgres, Drizzle, shadcn/ui e Tailwind CSS. Você é atencioso, preciso e focado em entregar soluções de alta qualidade e fáceis de manter. 7 | 8 | Tecnologias e ferramentas utilizadas: 9 | - Next.js 15 (App Router) 10 | - TypeScript 11 | - Tailwind CSS 12 | - shadcn/ui 13 | - React Hook Form para formulários 14 | - Zod para validações 15 | - BetterAuth para autenticação 16 | - PostgreSQL como banco de dados 17 | - Drizzle como ORM 18 | 19 | Princípios Principais: 20 | 21 | - Escreva um código limpo, conciso e fácil de manter, seguindo princípios do SOLID e Clean Code. 22 | - Use nomes de variáveis descritivos (exemplos: isLoading, hasError). 23 | - Use kebab-case para nomes de pastas e arquivos. 24 | - Sempre use TypeScript para escrever código. 25 | - DRY (Don't Repeat Yourself). Evite duplicidade de código. Quando necessário, crie funções/componentes reutilizáveis. 26 | 27 | React/Next.js 28 | - Sempre use Tailwind para estilização. 29 | - Use componentes da biblioteca shadcn/ui o máximo possível ao criar/modificar components (veja https://ui.shadcn.com/ para a lista de componentes disponíveis). 30 | - Sempre use Zod para validação de formulários. 31 | - Sempre use React Hook Form para criação e validação de formulários. Use o componente [form.tsx](mdc:src/components/ui/form.tsx) para criar esses formulários. Exemplo: [upsert-doctor-form.tsx](mdc:src/app/(protected)/doctors/_components/upsert-doctor-form.tsx). 32 | - Quando necessário, crie componentes e funções reutilizáveis para reduzir a duplicidade de código. 33 | - Quando um componente for utilizado apenas em uma página específica, crie-o na pasta "_components" dentro da pasta da respectiva página. 34 | - Sempre use a biblioteca "next-safe-action" ao criar com Server Actions. Use a Server Exemplo: [index.ts](mdc:src/actions/upsert-doctor/index.ts). 35 | - Sempre use o hook "useAction" da biblioteca "next-safe-actions" ao chamar Server Actions em componentes. Exemplo: [upsert-doctor-form.tsx](mdc:src/app/(protected)/doctors/_components/upsert-doctor-form.tsx). 36 | - As Server Actions devem ser armazenadas em `src/actions` (siga o padrão de nomenclatura das já existentes). 37 | - Sempre que for necessário interagir com o banco de dados, use o [index.ts](mdc:src/db/index.ts). 38 | - Usamos a biblioteca "dayjs" para manipular e formatar datas. 39 | - Ao criar páginas, use os componentes dentro de [page-container.tsx](mdc:src/components/ui/page-container.tsx) para manter os padrões de margin, padding e spacing nas páginas. Exemplo: [page.tsx](mdc:src/app/(protected)/doctors/page.tsx). 40 | - Sempre use a biblioteca "react-number-format" ao criar máscaras para inputs. 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/actions/get-available-times/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import dayjs from "dayjs"; 4 | import timezone from "dayjs/plugin/timezone"; 5 | import utc from "dayjs/plugin/utc"; 6 | import { eq } from "drizzle-orm"; 7 | import { z } from "zod"; 8 | 9 | import { db } from "@/db"; 10 | import { appointmentsTable, doctorsTable } from "@/db/schema"; 11 | import { generateTimeSlots } from "@/helpers/time"; 12 | import { protectedWithClinicActionClient } from "@/lib/next-safe-action"; 13 | 14 | dayjs.extend(utc); 15 | dayjs.extend(timezone); 16 | 17 | export const getAvailableTimes = protectedWithClinicActionClient 18 | .schema( 19 | z.object({ 20 | doctorId: z.string(), 21 | date: z.string().date(), // YYYY-MM-DD, 22 | }), 23 | ) 24 | .action(async ({ parsedInput }) => { 25 | const doctor = await db.query.doctorsTable.findFirst({ 26 | where: eq(doctorsTable.id, parsedInput.doctorId), 27 | }); 28 | if (!doctor) { 29 | throw new Error("Médico não encontrado"); 30 | } 31 | const selectedDayOfWeek = dayjs(parsedInput.date).day(); 32 | const doctorIsAvailable = 33 | selectedDayOfWeek >= doctor.availableFromWeekDay && 34 | selectedDayOfWeek <= doctor.availableToWeekDay; 35 | if (!doctorIsAvailable) { 36 | return []; 37 | } 38 | const appointments = await db.query.appointmentsTable.findMany({ 39 | where: eq(appointmentsTable.doctorId, parsedInput.doctorId), 40 | }); 41 | const appointmentsOnSelectedDate = appointments 42 | .filter((appointment) => { 43 | return dayjs(appointment.date).isSame(parsedInput.date, "day"); 44 | }) 45 | .map((appointment) => dayjs(appointment.date).format("HH:mm:ss")); 46 | const timeSlots = generateTimeSlots(); 47 | 48 | const doctorAvailableFrom = dayjs() 49 | .utc() 50 | .set("hour", Number(doctor.availableFromTime.split(":")[0])) 51 | .set("minute", Number(doctor.availableFromTime.split(":")[1])) 52 | .set("second", 0) 53 | .local(); 54 | const doctorAvailableTo = dayjs() 55 | .utc() 56 | .set("hour", Number(doctor.availableToTime.split(":")[0])) 57 | .set("minute", Number(doctor.availableToTime.split(":")[1])) 58 | .set("second", 0) 59 | .local(); 60 | const doctorTimeSlots = timeSlots.filter((time) => { 61 | const date = dayjs() 62 | .utc() 63 | .set("hour", Number(time.split(":")[0])) 64 | .set("minute", Number(time.split(":")[1])) 65 | .set("second", 0); 66 | 67 | return ( 68 | date.format("HH:mm:ss") >= doctorAvailableFrom.format("HH:mm:ss") && 69 | date.format("HH:mm:ss") <= doctorAvailableTo.format("HH:mm:ss") 70 | ); 71 | }); 72 | return doctorTimeSlots.map((time) => { 73 | return { 74 | value: time, 75 | available: !appointmentsOnSelectedDate.includes(time), 76 | label: time.substring(0, 5), 77 | }; 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Table({ className, ...props }: React.ComponentProps<"table">) { 8 | return ( 9 |
13 | 18 | 19 | ) 20 | } 21 | 22 | function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { 23 | return ( 24 | 29 | ) 30 | } 31 | 32 | function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { 33 | return ( 34 | 39 | ) 40 | } 41 | 42 | function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { 43 | return ( 44 | tr]:last:border-b-0", 48 | className 49 | )} 50 | {...props} 51 | /> 52 | ) 53 | } 54 | 55 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) { 56 | return ( 57 | 65 | ) 66 | } 67 | 68 | function TableHead({ className, ...props }: React.ComponentProps<"th">) { 69 | return ( 70 |
[role=checkbox]]:translate-y-[2px]", 74 | className 75 | )} 76 | {...props} 77 | /> 78 | ) 79 | } 80 | 81 | function TableCell({ className, ...props }: React.ComponentProps<"td">) { 82 | return ( 83 | [role=checkbox]]:translate-y-[2px]", 87 | className 88 | )} 89 | {...props} 90 | /> 91 | ) 92 | } 93 | 94 | function TableCaption({ 95 | className, 96 | ...props 97 | }: React.ComponentProps<"caption">) { 98 | return ( 99 |
104 | ) 105 | } 106 | 107 | export { 108 | Table, 109 | TableBody, 110 | TableCaption, 111 | TableCell, 112 | TableFooter, 113 | TableHead, 114 | TableHeader, 115 | TableRow, 116 | } 117 | -------------------------------------------------------------------------------- /src/components/ui/calendar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ChevronLeft, ChevronRight } from "lucide-react" 4 | import * as React from "react" 5 | import { DayPicker } from "react-day-picker" 6 | 7 | import { buttonVariants } from "@/components/ui/button" 8 | import { cn } from "@/lib/utils" 9 | 10 | function Calendar({ 11 | className, 12 | classNames, 13 | showOutsideDays = true, 14 | ...props 15 | }: React.ComponentProps) { 16 | return ( 17 | .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" 41 | : "[&:has([aria-selected])]:rounded-md" 42 | ), 43 | day: cn( 44 | buttonVariants({ variant: "ghost" }), 45 | "size-8 p-0 font-normal aria-selected:opacity-100" 46 | ), 47 | day_range_start: 48 | "day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground", 49 | day_range_end: 50 | "day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground", 51 | day_selected: 52 | "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", 53 | day_today: "bg-accent text-accent-foreground", 54 | day_outside: 55 | "day-outside text-muted-foreground aria-selected:text-muted-foreground", 56 | day_disabled: "text-muted-foreground opacity-50", 57 | day_range_middle: 58 | "aria-selected:bg-accent aria-selected:text-accent-foreground", 59 | day_hidden: "invisible", 60 | ...classNames, 61 | }} 62 | components={{ 63 | IconLeft: ({ className, ...props }) => ( 64 | 65 | ), 66 | IconRight: ({ className, ...props }) => ( 67 | 68 | ), 69 | }} 70 | {...props} 71 | /> 72 | ) 73 | } 74 | 75 | export { Calendar } 76 | -------------------------------------------------------------------------------- /src/app/(protected)/patients/_components/patient-card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Mail, Phone, User } from "lucide-react"; 4 | import { useState } from "react"; 5 | 6 | import { Avatar, AvatarFallback } from "@/components/ui/avatar"; 7 | import { Badge } from "@/components/ui/badge"; 8 | import { Button } from "@/components/ui/button"; 9 | import { 10 | Card, 11 | CardContent, 12 | CardFooter, 13 | CardHeader, 14 | } from "@/components/ui/card"; 15 | import { Dialog, DialogTrigger } from "@/components/ui/dialog"; 16 | import { Separator } from "@/components/ui/separator"; 17 | import { patientsTable } from "@/db/schema"; 18 | 19 | import UpsertPatientForm from "./upsert-patient-form"; 20 | 21 | interface PatientCardProps { 22 | patient: typeof patientsTable.$inferSelect; 23 | } 24 | 25 | const PatientCard = ({ patient }: PatientCardProps) => { 26 | const [isUpsertPatientDialogOpen, setIsUpsertPatientDialogOpen] = 27 | useState(false); 28 | 29 | const patientInitials = patient.name 30 | .split(" ") 31 | .map((name) => name[0]) 32 | .join(""); 33 | 34 | const formatPhoneNumber = (phone: string) => { 35 | // Remove all non-numeric characters 36 | const cleaned = phone.replace(/\D/g, ""); 37 | // Format as (XX) XXXXX-XXXX 38 | if (cleaned.length === 11) { 39 | return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2, 7)}-${cleaned.slice(7)}`; 40 | } 41 | return phone; 42 | }; 43 | 44 | const getSexLabel = (sex: "male" | "female") => { 45 | return sex === "male" ? "Masculino" : "Feminino"; 46 | }; 47 | 48 | return ( 49 | 50 | 51 |
52 | 53 | {patientInitials} 54 | 55 |
56 |

{patient.name}

57 |

58 | {getSexLabel(patient.sex)} 59 |

60 |
61 |
62 |
63 | 64 | 65 | 66 | 67 | {patient.email} 68 | 69 | 70 | 71 | {formatPhoneNumber(patient.phoneNumber)} 72 | 73 | 74 | 75 | {getSexLabel(patient.sex)} 76 | 77 | 78 | 79 | 80 | 84 | 85 | 86 | 87 | setIsUpsertPatientDialogOpen(false)} 90 | isOpen={isUpsertPatientDialogOpen} 91 | /> 92 | 93 | 94 |
95 | ); 96 | }; 97 | 98 | export default PatientCard; 99 | -------------------------------------------------------------------------------- /src/app/(protected)/dashboard/_components/top-specialties.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Activity, 3 | Baby, 4 | Bone, 5 | Brain, 6 | Eye, 7 | Hand, 8 | Heart, 9 | Hospital, 10 | Stethoscope, 11 | } from "lucide-react"; 12 | 13 | import { Card, CardContent, CardTitle } from "@/components/ui/card"; 14 | import { Progress } from "@/components/ui/progress"; 15 | 16 | interface TopSpecialtiesProps { 17 | topSpecialties: { 18 | specialty: string; 19 | appointments: number; 20 | }[]; 21 | } 22 | 23 | const getSpecialtyIcon = (specialty: string) => { 24 | const specialtyLower = specialty.toLowerCase(); 25 | 26 | if (specialtyLower.includes("cardiolog")) return Heart; 27 | if ( 28 | specialtyLower.includes("ginecolog") || 29 | specialtyLower.includes("obstetri") 30 | ) 31 | return Baby; 32 | if (specialtyLower.includes("pediatr")) return Activity; 33 | if (specialtyLower.includes("dermatolog")) return Hand; 34 | if ( 35 | specialtyLower.includes("ortoped") || 36 | specialtyLower.includes("traumatolog") 37 | ) 38 | return Bone; 39 | if (specialtyLower.includes("oftalmolog")) return Eye; 40 | if (specialtyLower.includes("neurolog")) return Brain; 41 | 42 | return Stethoscope; 43 | }; 44 | 45 | export default function TopSpecialties({ 46 | topSpecialties, 47 | }: TopSpecialtiesProps) { 48 | const maxAppointments = Math.max( 49 | ...topSpecialties.map((i) => i.appointments), 50 | ); 51 | return ( 52 | 53 | 54 |
55 |
56 | 57 | Especialidades 58 |
59 |
60 | 61 | {/* specialtys List */} 62 |
63 | {topSpecialties.map((specialty) => { 64 | const Icon = getSpecialtyIcon(specialty.specialty); 65 | // Porcentagem de ocupação da especialidade baseando-se no maior número de agendamentos 66 | const progressValue = 67 | (specialty.appointments / maxAppointments) * 100; 68 | 69 | return ( 70 |
74 |
75 | 76 |
77 |
78 |
79 |

{specialty.specialty}

80 |
81 | 82 | {specialty.appointments} agend. 83 | 84 |
85 |
86 | 87 |
88 |
89 | ); 90 | })} 91 |
92 |
93 |
94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /src/app/(protected)/appointments/_components/table-actions.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { MoreVerticalIcon, TrashIcon } from "lucide-react"; 4 | import { useAction } from "next-safe-action/hooks"; 5 | import { toast } from "sonner"; 6 | 7 | import { deleteAppointment } from "@/actions/delete-appointment"; 8 | import { 9 | AlertDialog, 10 | AlertDialogAction, 11 | AlertDialogCancel, 12 | AlertDialogContent, 13 | AlertDialogDescription, 14 | AlertDialogFooter, 15 | AlertDialogHeader, 16 | AlertDialogTitle, 17 | AlertDialogTrigger, 18 | } from "@/components/ui/alert-dialog"; 19 | import { Button } from "@/components/ui/button"; 20 | import { 21 | DropdownMenu, 22 | DropdownMenuContent, 23 | DropdownMenuItem, 24 | DropdownMenuLabel, 25 | DropdownMenuSeparator, 26 | DropdownMenuTrigger, 27 | } from "@/components/ui/dropdown-menu"; 28 | import { appointmentsTable } from "@/db/schema"; 29 | 30 | type AppointmentWithRelations = typeof appointmentsTable.$inferSelect & { 31 | patient: { 32 | id: string; 33 | name: string; 34 | email: string; 35 | phoneNumber: string; 36 | sex: "male" | "female"; 37 | }; 38 | doctor: { 39 | id: string; 40 | name: string; 41 | specialty: string; 42 | }; 43 | }; 44 | 45 | interface AppointmentsTableActionsProps { 46 | appointment: AppointmentWithRelations; 47 | } 48 | 49 | const AppointmentsTableActions = ({ 50 | appointment, 51 | }: AppointmentsTableActionsProps) => { 52 | const deleteAppointmentAction = useAction(deleteAppointment, { 53 | onSuccess: () => { 54 | toast.success("Agendamento deletado com sucesso."); 55 | }, 56 | onError: () => { 57 | toast.error("Erro ao deletar agendamento."); 58 | }, 59 | }); 60 | 61 | const handleDeleteAppointmentClick = () => { 62 | if (!appointment) return; 63 | deleteAppointmentAction.execute({ id: appointment.id }); 64 | }; 65 | 66 | return ( 67 | 68 | 69 | 72 | 73 | 74 | {appointment.patient.name} 75 | 76 | 77 | 78 | e.preventDefault()}> 79 | 80 | Excluir 81 | 82 | 83 | 84 | 85 | 86 | Tem certeza que deseja deletar esse agendamento? 87 | 88 | 89 | Essa ação não pode ser revertida. Isso irá deletar o agendamento 90 | permanentemente. 91 | 92 | 93 | 94 | Cancelar 95 | 96 | Deletar 97 | 98 | 99 | 100 | 101 | 102 | 103 | ); 104 | }; 105 | 106 | export default AppointmentsTableActions; 107 | -------------------------------------------------------------------------------- /src/app/(protected)/patients/_components/table-actions.tsx: -------------------------------------------------------------------------------- 1 | import { EditIcon, MoreVerticalIcon, TrashIcon } from "lucide-react"; 2 | import { useAction } from "next-safe-action/hooks"; 3 | import { useState } from "react"; 4 | import { toast } from "sonner"; 5 | 6 | import { deletePatient } from "@/actions/delete-patient"; 7 | import { 8 | AlertDialog, 9 | AlertDialogAction, 10 | AlertDialogCancel, 11 | AlertDialogContent, 12 | AlertDialogDescription, 13 | AlertDialogFooter, 14 | AlertDialogHeader, 15 | AlertDialogTitle, 16 | AlertDialogTrigger, 17 | } from "@/components/ui/alert-dialog"; 18 | import { Button } from "@/components/ui/button"; 19 | import { Dialog } from "@/components/ui/dialog"; 20 | import { 21 | DropdownMenu, 22 | DropdownMenuContent, 23 | DropdownMenuItem, 24 | DropdownMenuLabel, 25 | DropdownMenuSeparator, 26 | DropdownMenuTrigger, 27 | } from "@/components/ui/dropdown-menu"; 28 | import { patientsTable } from "@/db/schema"; 29 | 30 | import UpsertPatientForm from "./upsert-patient-form"; 31 | 32 | interface PatientsTableActionsProps { 33 | patient: typeof patientsTable.$inferSelect; 34 | } 35 | 36 | const PatientsTableActions = ({ patient }: PatientsTableActionsProps) => { 37 | const [upsertDialogIsOpen, setUpsertDialogIsOpen] = useState(false); 38 | 39 | const deletePatientAction = useAction(deletePatient, { 40 | onSuccess: () => { 41 | toast.success("Paciente deletado com sucesso."); 42 | }, 43 | onError: () => { 44 | toast.error("Erro ao deletar paciente."); 45 | }, 46 | }); 47 | 48 | const handleDeletePatientClick = () => { 49 | if (!patient) return; 50 | deletePatientAction.execute({ id: patient.id }); 51 | }; 52 | 53 | return ( 54 | <> 55 | 56 | 57 | 58 | 61 | 62 | 63 | {patient.name} 64 | 65 | setUpsertDialogIsOpen(true)}> 66 | 67 | Editar 68 | 69 | 70 | 71 | e.preventDefault()}> 72 | 73 | Excluir 74 | 75 | 76 | 77 | 78 | 79 | Tem certeza que deseja deletar esse paciente? 80 | 81 | 82 | Essa ação não pode ser revertida. Isso irá deletar o 83 | paciente e todas as consultas agendadas. 84 | 85 | 86 | 87 | Cancelar 88 | 89 | Deletar 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | setUpsertDialogIsOpen(false)} 101 | /> 102 | 103 | 104 | ); 105 | }; 106 | 107 | export default PatientsTableActions; 108 | -------------------------------------------------------------------------------- /src/app/(protected)/subscription/_components/subscription-plan.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { loadStripe } from "@stripe/stripe-js"; 4 | import { CheckCircle2, Loader2 } from "lucide-react"; 5 | import { useRouter } from "next/navigation"; 6 | import { useAction } from "next-safe-action/hooks"; 7 | 8 | import { createStripeCheckout } from "@/actions/create-stripe-checkout"; 9 | import { Badge } from "@/components/ui/badge"; 10 | import { Button } from "@/components/ui/button"; 11 | import { Card, CardContent, CardHeader } from "@/components/ui/card"; 12 | 13 | interface SubscriptionPlanProps { 14 | active?: boolean; 15 | className?: string; 16 | userEmail: string; 17 | } 18 | 19 | export function SubscriptionPlan({ 20 | active = false, 21 | className, 22 | userEmail, 23 | }: SubscriptionPlanProps) { 24 | const router = useRouter(); 25 | const createStripeCheckoutAction = useAction(createStripeCheckout, { 26 | onSuccess: async ({ data }) => { 27 | if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { 28 | throw new Error("Stripe publishable key not found"); 29 | } 30 | const stripe = await loadStripe( 31 | process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, 32 | ); 33 | if (!stripe) { 34 | throw new Error("Stripe not found"); 35 | } 36 | if (!data?.sessionId) { 37 | throw new Error("Session ID not found"); 38 | } 39 | await stripe.redirectToCheckout({ 40 | sessionId: data.sessionId, 41 | }); 42 | }, 43 | }); 44 | const features = [ 45 | "Cadastro de até 3 médicos", 46 | "Agendamentos ilimitados", 47 | "Métricas básicas", 48 | "Cadastro de pacientes", 49 | "Confirmação manual", 50 | "Suporte via e-mail", 51 | ]; 52 | 53 | const handleSubscribeClick = () => { 54 | createStripeCheckoutAction.execute(); 55 | }; 56 | 57 | const handleManagePlanClick = () => { 58 | router.push( 59 | `${process.env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL_URL}?prefilled_email=${userEmail}`, 60 | ); 61 | }; 62 | 63 | return ( 64 | 65 | 66 |
67 |

Essential

68 | {active && ( 69 | 70 | Atual 71 | 72 | )} 73 |
74 |

75 | Para profissionais autônomos ou pequenas clínicas 76 |

77 |
78 | R$59 79 | / mês 80 |
81 |
82 | 83 | 84 |
85 | {features.map((feature, index) => ( 86 |
87 |
88 | 89 |
90 |

{feature}

91 |
92 | ))} 93 |
94 | 95 |
96 | 110 |
111 |
112 |
113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /src/app/(protected)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { Calendar } from "lucide-react"; 3 | import { headers } from "next/headers"; 4 | import { redirect } from "next/navigation"; 5 | 6 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 7 | import { DataTable } from "@/components/ui/data-table"; 8 | import { 9 | PageActions, 10 | PageContainer, 11 | PageContent, 12 | PageDescription, 13 | PageHeader, 14 | PageHeaderContent, 15 | PageTitle, 16 | } from "@/components/ui/page-container"; 17 | import { getDashboard } from "@/data/get-dashboard"; 18 | import WithAuthentication from "@/hocs/with-authentication"; 19 | import { auth } from "@/lib/auth"; 20 | 21 | import { appointmentsTableColumns } from "../appointments/_components/table-columns"; 22 | import AppointmentsChart from "./_components/appointments-chart"; 23 | import { DatePicker } from "./_components/date-picker"; 24 | import StatsCards from "./_components/stats-cards"; 25 | import TopDoctors from "./_components/top-doctors"; 26 | import TopSpecialties from "./_components/top-specialties"; 27 | 28 | interface DashboardPageProps { 29 | searchParams: Promise<{ 30 | from: string; 31 | to: string; 32 | }>; 33 | } 34 | 35 | const DashboardPage = async ({ searchParams }: DashboardPageProps) => { 36 | const session = await auth.api.getSession({ 37 | headers: await headers(), 38 | }); 39 | const { from, to } = await searchParams; 40 | if (!from || !to) { 41 | redirect( 42 | `/dashboard?from=${dayjs().format("YYYY-MM-DD")}&to=${dayjs().add(1, "month").format("YYYY-MM-DD")}`, 43 | ); 44 | } 45 | const { 46 | totalRevenue, 47 | totalAppointments, 48 | totalPatients, 49 | totalDoctors, 50 | topDoctors, 51 | topSpecialties, 52 | todayAppointments, 53 | dailyAppointmentsData, 54 | } = await getDashboard({ 55 | from, 56 | to, 57 | session: { 58 | user: { 59 | clinic: { 60 | id: session!.user.clinic!.id, 61 | }, 62 | }, 63 | }, 64 | }); 65 | 66 | return ( 67 | 68 | 69 | 70 | 71 | Dashboard 72 | 73 | Tenha uma visão geral da sua clínica. 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 89 |
90 | 91 | 92 |
93 |
94 | 95 | 96 |
97 | 98 | 99 | Agendamentos de hoje 100 | 101 |
102 |
103 | 104 | 108 | 109 |
110 | 111 |
112 |
113 |
114 |
115 | ); 116 | }; 117 | 118 | export default DashboardPage; 119 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as DialogPrimitive from "@radix-ui/react-dialog" 4 | import { XIcon } from "lucide-react" 5 | import * as React from "react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Dialog({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function DialogTrigger({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return 19 | } 20 | 21 | function DialogPortal({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return 25 | } 26 | 27 | function DialogClose({ 28 | ...props 29 | }: React.ComponentProps) { 30 | return 31 | } 32 | 33 | function DialogOverlay({ 34 | className, 35 | ...props 36 | }: React.ComponentProps) { 37 | return ( 38 | 46 | ) 47 | } 48 | 49 | function DialogContent({ 50 | className, 51 | children, 52 | ...props 53 | }: React.ComponentProps) { 54 | return ( 55 | 56 | 57 | 65 | {children} 66 | 67 | 68 | Close 69 | 70 | 71 | 72 | ) 73 | } 74 | 75 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { 76 | return ( 77 |
82 | ) 83 | } 84 | 85 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { 86 | return ( 87 |
95 | ) 96 | } 97 | 98 | function DialogTitle({ 99 | className, 100 | ...props 101 | }: React.ComponentProps) { 102 | return ( 103 | 108 | ) 109 | } 110 | 111 | function DialogDescription({ 112 | className, 113 | ...props 114 | }: React.ComponentProps) { 115 | return ( 116 | 121 | ) 122 | } 123 | 124 | export { 125 | Dialog, 126 | DialogClose, 127 | DialogContent, 128 | DialogDescription, 129 | DialogFooter, 130 | DialogHeader, 131 | DialogOverlay, 132 | DialogPortal, 133 | DialogTitle, 134 | DialogTrigger, 135 | } 136 | -------------------------------------------------------------------------------- /src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as LabelPrimitive from "@radix-ui/react-label" 4 | import { Slot } from "@radix-ui/react-slot" 5 | import * as React from "react" 6 | import { 7 | Controller, 8 | type ControllerProps, 9 | type FieldPath, 10 | type FieldValues, 11 | FormProvider, 12 | useFormContext, 13 | useFormState, 14 | } from "react-hook-form" 15 | 16 | import { Label } from "@/components/ui/label" 17 | import { cn } from "@/lib/utils" 18 | 19 | const Form = FormProvider 20 | 21 | type FormFieldContextValue< 22 | TFieldValues extends FieldValues = FieldValues, 23 | TName extends FieldPath = FieldPath, 24 | > = { 25 | name: TName 26 | } 27 | 28 | const FormFieldContext = React.createContext( 29 | {} as FormFieldContextValue 30 | ) 31 | 32 | const FormField = < 33 | TFieldValues extends FieldValues = FieldValues, 34 | TName extends FieldPath = FieldPath, 35 | >({ 36 | ...props 37 | }: ControllerProps) => { 38 | return ( 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | const useFormField = () => { 46 | const fieldContext = React.useContext(FormFieldContext) 47 | const itemContext = React.useContext(FormItemContext) 48 | const { getFieldState } = useFormContext() 49 | const formState = useFormState({ name: fieldContext.name }) 50 | const fieldState = getFieldState(fieldContext.name, formState) 51 | 52 | if (!fieldContext) { 53 | throw new Error("useFormField should be used within ") 54 | } 55 | 56 | const { id } = itemContext 57 | 58 | return { 59 | id, 60 | name: fieldContext.name, 61 | formItemId: `${id}-form-item`, 62 | formDescriptionId: `${id}-form-item-description`, 63 | formMessageId: `${id}-form-item-message`, 64 | ...fieldState, 65 | } 66 | } 67 | 68 | type FormItemContextValue = { 69 | id: string 70 | } 71 | 72 | const FormItemContext = React.createContext( 73 | {} as FormItemContextValue 74 | ) 75 | 76 | function FormItem({ className, ...props }: React.ComponentProps<"div">) { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
86 | 87 | ) 88 | } 89 | 90 | function FormLabel({ 91 | className, 92 | ...props 93 | }: React.ComponentProps) { 94 | const { error, formItemId } = useFormField() 95 | 96 | return ( 97 |