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 | Ver detalhes
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 |
70 |
71 |
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 |
59 |
60 |
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 |
102 | {createStripeCheckoutAction.isExecuting ? (
103 |
104 | ) : active ? (
105 | "Gerenciar assinatura"
106 | ) : (
107 | "Fazer assinatura"
108 | )}
109 |
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 |
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 |
104 | )
105 | }
106 |
107 | function FormControl({ ...props }: React.ComponentProps) {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | }
124 |
125 | function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
126 | const { formDescriptionId } = useFormField()
127 |
128 | return (
129 |
135 | )
136 | }
137 |
138 | function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
139 | const { error, formMessageId } = useFormField()
140 | const body = error ? String(error?.message ?? "") : props.children
141 |
142 | if (!body) {
143 | return null
144 | }
145 |
146 | return (
147 |
153 | {body}
154 |
155 | )
156 | }
157 |
158 | export {
159 | Form,
160 | FormControl,
161 | FormDescription,
162 | FormField,
163 | FormItem,
164 | FormLabel,
165 | FormMessage,
166 | useFormField,
167 | }
168 |
--------------------------------------------------------------------------------
/src/app/(protected)/_components/app-sidebar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | CalendarDays,
5 | Gem,
6 | LayoutDashboard,
7 | LogOut,
8 | Stethoscope,
9 | UsersRound,
10 | } from "lucide-react";
11 | import Image from "next/image";
12 | import Link from "next/link";
13 | import { usePathname, useRouter } from "next/navigation";
14 |
15 | import { Avatar, AvatarFallback } from "@/components/ui/avatar";
16 | import {
17 | DropdownMenu,
18 | DropdownMenuContent,
19 | DropdownMenuItem,
20 | DropdownMenuTrigger,
21 | } from "@/components/ui/dropdown-menu";
22 | import {
23 | Sidebar,
24 | SidebarContent,
25 | SidebarFooter,
26 | SidebarGroup,
27 | SidebarGroupContent,
28 | SidebarGroupLabel,
29 | SidebarHeader,
30 | SidebarMenu,
31 | SidebarMenuButton,
32 | SidebarMenuItem,
33 | } from "@/components/ui/sidebar";
34 | import { authClient } from "@/lib/auth-client";
35 |
36 | const items = [
37 | {
38 | title: "Dashboard",
39 | url: "/dashboard",
40 | icon: LayoutDashboard,
41 | },
42 | {
43 | title: "Agendamentos",
44 | url: "/appointments",
45 | icon: CalendarDays,
46 | },
47 | {
48 | title: "Médicos",
49 | url: "/doctors",
50 | icon: Stethoscope,
51 | },
52 | {
53 | title: "Pacientes",
54 | url: "/patients",
55 | icon: UsersRound,
56 | },
57 | ];
58 |
59 | export function AppSidebar() {
60 | const router = useRouter();
61 | const session = authClient.useSession();
62 | const pathname = usePathname();
63 |
64 | const handleSignOut = async () => {
65 | await authClient.signOut({
66 | fetchOptions: {
67 | onSuccess: () => {
68 | router.push("/authentication");
69 | },
70 | },
71 | });
72 | };
73 | return (
74 |
75 |
76 |
77 |
78 |
79 |
80 | Menu Principal
81 |
82 |
83 | {items.map((item) => (
84 |
85 |
86 |
87 |
88 | {item.title}
89 |
90 |
91 |
92 | ))}
93 |
94 |
95 |
96 |
97 | Outros
98 |
99 |
100 |
101 |
105 |
106 |
107 | Assinatura
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 | F
123 |
124 |
125 |
126 | {session.data?.user?.clinic?.name}
127 |
128 |
129 | {session.data?.user.email}
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 | Sair
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 | );
146 | }
147 |
--------------------------------------------------------------------------------
/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
4 | import * as React from "react"
5 |
6 | import { buttonVariants } from "@/components/ui/button"
7 | import { cn } from "@/lib/utils"
8 |
9 | function AlertDialog({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function AlertDialogTrigger({
16 | ...props
17 | }: React.ComponentProps) {
18 | return (
19 |
20 | )
21 | }
22 |
23 | function AlertDialogPortal({
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
28 | )
29 | }
30 |
31 | function AlertDialogOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | )
45 | }
46 |
47 | function AlertDialogContent({
48 | className,
49 | ...props
50 | }: React.ComponentProps) {
51 | return (
52 |
53 |
54 |
62 |
63 | )
64 | }
65 |
66 | function AlertDialogHeader({
67 | className,
68 | ...props
69 | }: React.ComponentProps<"div">) {
70 | return (
71 |
76 | )
77 | }
78 |
79 | function AlertDialogFooter({
80 | className,
81 | ...props
82 | }: React.ComponentProps<"div">) {
83 | return (
84 |
92 | )
93 | }
94 |
95 | function AlertDialogTitle({
96 | className,
97 | ...props
98 | }: React.ComponentProps) {
99 | return (
100 |
105 | )
106 | }
107 |
108 | function AlertDialogDescription({
109 | className,
110 | ...props
111 | }: React.ComponentProps) {
112 | return (
113 |
118 | )
119 | }
120 |
121 | function AlertDialogAction({
122 | className,
123 | ...props
124 | }: React.ComponentProps) {
125 | return (
126 |
130 | )
131 | }
132 |
133 | function AlertDialogCancel({
134 | className,
135 | ...props
136 | }: React.ComponentProps) {
137 | return (
138 |
142 | )
143 | }
144 |
145 | export {
146 | AlertDialog,
147 | AlertDialogAction,
148 | AlertDialogCancel,
149 | AlertDialogContent,
150 | AlertDialogDescription,
151 | AlertDialogFooter,
152 | AlertDialogHeader,
153 | AlertDialogOverlay,
154 | AlertDialogPortal,
155 | AlertDialogTitle,
156 | AlertDialogTrigger,
157 | }
158 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as SheetPrimitive 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 Sheet({ ...props }: React.ComponentProps) {
10 | return
11 | }
12 |
13 | function SheetTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return
17 | }
18 |
19 | function SheetClose({
20 | ...props
21 | }: React.ComponentProps) {
22 | return
23 | }
24 |
25 | function SheetPortal({
26 | ...props
27 | }: React.ComponentProps) {
28 | return
29 | }
30 |
31 | function SheetOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | )
45 | }
46 |
47 | function SheetContent({
48 | className,
49 | children,
50 | side = "right",
51 | ...props
52 | }: React.ComponentProps & {
53 | side?: "top" | "right" | "bottom" | "left"
54 | }) {
55 | return (
56 |
57 |
58 |
74 | {children}
75 |
76 |
77 | Close
78 |
79 |
80 |
81 | )
82 | }
83 |
84 | function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
85 | return (
86 |
91 | )
92 | }
93 |
94 | function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
95 | return (
96 |
101 | )
102 | }
103 |
104 | function SheetTitle({
105 | className,
106 | ...props
107 | }: React.ComponentProps) {
108 | return (
109 |
114 | )
115 | }
116 |
117 | function SheetDescription({
118 | className,
119 | ...props
120 | }: React.ComponentProps) {
121 | return (
122 |
127 | )
128 | }
129 |
130 | export {
131 | Sheet,
132 | SheetClose,
133 | SheetContent,
134 | SheetDescription,
135 | SheetFooter,
136 | SheetHeader,
137 | SheetTitle,
138 | SheetTrigger,
139 | }
140 |
--------------------------------------------------------------------------------
/src/app/authentication/components/sign-up-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import { Loader2 } from "lucide-react";
5 | import { useRouter } from "next/navigation";
6 | import { useForm } from "react-hook-form";
7 | import { toast } from "sonner";
8 | import { z } from "zod";
9 |
10 | import { Button } from "@/components/ui/button";
11 | import {
12 | Card,
13 | CardContent,
14 | CardDescription,
15 | CardFooter,
16 | CardHeader,
17 | CardTitle,
18 | } from "@/components/ui/card";
19 | import { FormControl, FormMessage } from "@/components/ui/form";
20 | import { FormItem, FormLabel } from "@/components/ui/form";
21 | import { Form, FormField } from "@/components/ui/form";
22 | import { Input } from "@/components/ui/input";
23 | import { authClient } from "@/lib/auth-client";
24 |
25 | const registerSchema = z.object({
26 | name: z.string().trim().min(1, { message: "Nome é obrigatório" }),
27 | email: z
28 | .string()
29 | .trim()
30 | .min(1, { message: "E-mail é obrigatório" })
31 | .email({ message: "E-mail inválido" }),
32 | password: z
33 | .string()
34 | .trim()
35 | .min(8, { message: "A senha deve ter pelo menos 8 caracteres" }),
36 | });
37 |
38 | const SignUpForm = () => {
39 | const router = useRouter();
40 | const form = useForm>({
41 | resolver: zodResolver(registerSchema),
42 | defaultValues: {
43 | name: "",
44 | email: "",
45 | password: "",
46 | },
47 | });
48 |
49 | async function onSubmit(values: z.infer) {
50 | await authClient.signUp.email(
51 | {
52 | email: values.email,
53 | password: values.password,
54 | name: values.name,
55 | },
56 | {
57 | onSuccess: () => {
58 | router.push("/dashboard");
59 | },
60 | onError: (ctx) => {
61 | if (ctx.error.code === "USER_ALREADY_EXISTS") {
62 | toast.error("E-mail já cadastrado.");
63 | return;
64 | }
65 | toast.error("Erro ao criar conta.");
66 | },
67 | },
68 | );
69 | }
70 |
71 | return (
72 |
73 |
138 |
139 |
140 | );
141 | };
142 |
143 | export default SignUpForm;
144 |
--------------------------------------------------------------------------------
/src/data/get-dashboard.ts:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs";
2 | import { and, count, desc, eq, gte, lte, sql, sum } from "drizzle-orm";
3 |
4 | import { db } from "@/db";
5 | import { appointmentsTable, doctorsTable, patientsTable } from "@/db/schema";
6 |
7 | interface Params {
8 | from: string;
9 | to: string;
10 | session: {
11 | user: {
12 | clinic: {
13 | id: string;
14 | };
15 | };
16 | };
17 | }
18 |
19 | export const getDashboard = async ({ from, to, session }: Params) => {
20 | const chartStartDate = dayjs().subtract(10, "days").startOf("day").toDate();
21 | const chartEndDate = dayjs().add(10, "days").endOf("day").toDate();
22 | const [
23 | [totalRevenue],
24 | [totalAppointments],
25 | [totalPatients],
26 | [totalDoctors],
27 | topDoctors,
28 | topSpecialties,
29 | todayAppointments,
30 | dailyAppointmentsData,
31 | ] = await Promise.all([
32 | db
33 | .select({
34 | total: sum(appointmentsTable.appointmentPriceInCents),
35 | })
36 | .from(appointmentsTable)
37 | .where(
38 | and(
39 | eq(appointmentsTable.clinicId, session.user.clinic.id),
40 | gte(appointmentsTable.date, new Date(from)),
41 | lte(appointmentsTable.date, new Date(to)),
42 | ),
43 | ),
44 | db
45 | .select({
46 | total: count(),
47 | })
48 | .from(appointmentsTable)
49 | .where(
50 | and(
51 | eq(appointmentsTable.clinicId, session.user.clinic.id),
52 | gte(appointmentsTable.date, new Date(from)),
53 | lte(appointmentsTable.date, new Date(to)),
54 | ),
55 | ),
56 | db
57 | .select({
58 | total: count(),
59 | })
60 | .from(patientsTable)
61 | .where(eq(patientsTable.clinicId, session.user.clinic.id)),
62 | db
63 | .select({
64 | total: count(),
65 | })
66 | .from(doctorsTable)
67 | .where(eq(doctorsTable.clinicId, session.user.clinic.id)),
68 | db
69 | .select({
70 | id: doctorsTable.id,
71 | name: doctorsTable.name,
72 | avatarImageUrl: doctorsTable.avatarImageUrl,
73 | specialty: doctorsTable.specialty,
74 | appointments: count(appointmentsTable.id),
75 | })
76 | .from(doctorsTable)
77 | .leftJoin(
78 | appointmentsTable,
79 | and(
80 | eq(appointmentsTable.doctorId, doctorsTable.id),
81 | gte(appointmentsTable.date, new Date(from)),
82 | lte(appointmentsTable.date, new Date(to)),
83 | ),
84 | )
85 | .where(eq(doctorsTable.clinicId, session.user.clinic.id))
86 | .groupBy(doctorsTable.id)
87 | .orderBy(desc(count(appointmentsTable.id)))
88 | .limit(10),
89 | db
90 | .select({
91 | specialty: doctorsTable.specialty,
92 | appointments: count(appointmentsTable.id),
93 | })
94 | .from(appointmentsTable)
95 | .innerJoin(doctorsTable, eq(appointmentsTable.doctorId, doctorsTable.id))
96 | .where(
97 | and(
98 | eq(appointmentsTable.clinicId, session.user.clinic.id),
99 | gte(appointmentsTable.date, new Date(from)),
100 | lte(appointmentsTable.date, new Date(to)),
101 | ),
102 | )
103 | .groupBy(doctorsTable.specialty)
104 | .orderBy(desc(count(appointmentsTable.id))),
105 | db.query.appointmentsTable.findMany({
106 | where: and(
107 | eq(appointmentsTable.clinicId, session.user.clinic.id),
108 | gte(appointmentsTable.date, new Date()),
109 | lte(appointmentsTable.date, new Date()),
110 | ),
111 | with: {
112 | patient: true,
113 | doctor: true,
114 | },
115 | }),
116 | db
117 | .select({
118 | date: sql`DATE(${appointmentsTable.date})`.as("date"),
119 | appointments: count(appointmentsTable.id),
120 | revenue:
121 | sql`COALESCE(SUM(${appointmentsTable.appointmentPriceInCents}), 0)`.as(
122 | "revenue",
123 | ),
124 | })
125 | .from(appointmentsTable)
126 | .where(
127 | and(
128 | eq(appointmentsTable.clinicId, session.user.clinic.id),
129 | gte(appointmentsTable.date, chartStartDate),
130 | lte(appointmentsTable.date, chartEndDate),
131 | ),
132 | )
133 | .groupBy(sql`DATE(${appointmentsTable.date})`)
134 | .orderBy(sql`DATE(${appointmentsTable.date})`),
135 | ]);
136 | return {
137 | totalRevenue,
138 | totalAppointments,
139 | totalPatients,
140 | totalDoctors,
141 | topDoctors,
142 | topSpecialties,
143 | todayAppointments,
144 | dailyAppointmentsData,
145 | };
146 | };
147 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | @theme inline {
7 | --color-background: var(--background);
8 | --color-foreground: var(--foreground);
9 | --font-sans: var(--font-manrope);
10 | --color-sidebar-ring: var(--sidebar-ring);
11 | --color-sidebar-border: var(--sidebar-border);
12 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
13 | --color-sidebar-accent: var(--sidebar-accent);
14 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
15 | --color-sidebar-primary: var(--sidebar-primary);
16 | --color-sidebar-foreground: var(--sidebar-foreground);
17 | --color-sidebar: var(--sidebar);
18 | --color-chart-5: var(--chart-5);
19 | --color-chart-4: var(--chart-4);
20 | --color-chart-3: var(--chart-3);
21 | --color-chart-2: var(--chart-2);
22 | --color-chart-1: var(--chart-1);
23 | --color-ring: var(--ring);
24 | --color-input: var(--input);
25 | --color-border: var(--border);
26 | --color-destructive: var(--destructive);
27 | --color-accent-foreground: var(--accent-foreground);
28 | --color-accent: var(--accent);
29 | --color-muted-foreground: var(--muted-foreground);
30 | --color-muted: var(--muted);
31 | --color-secondary-foreground: var(--secondary-foreground);
32 | --color-secondary: var(--secondary);
33 | --color-primary-foreground: var(--primary-foreground);
34 | --color-primary: var(--primary);
35 | --color-popover-foreground: var(--popover-foreground);
36 | --color-popover: var(--popover);
37 | --color-card-foreground: var(--card-foreground);
38 | --color-card: var(--card);
39 | --radius-sm: calc(var(--radius) - 4px);
40 | --radius-md: calc(var(--radius) - 2px);
41 | --radius-lg: var(--radius);
42 | --radius-xl: calc(var(--radius) + 4px);
43 | }
44 |
45 | :root {
46 | --radius: 0.5rem;
47 | --background: oklch(1 0 0);
48 | --foreground: oklch(0.141 0.005 285.823);
49 | --card: oklch(1 0 0);
50 | --card-foreground: oklch(0.141 0.005 285.823);
51 | --popover: oklch(1 0 0);
52 | --popover-foreground: oklch(0.141 0.005 285.823);
53 | --primary: oklch(0.623 0.214 259.815);
54 | --primary-foreground: oklch(0.97 0.014 254.604);
55 | --secondary: oklch(0.967 0.001 286.375);
56 | --secondary-foreground: oklch(0.21 0.006 285.885);
57 | --muted: oklch(0.967 0.001 286.375);
58 | --muted-foreground: oklch(0.552 0.016 285.938);
59 | --accent: oklch(0.967 0.001 286.375);
60 | --accent-foreground: oklch(0.21 0.006 285.885);
61 | --destructive: oklch(0.577 0.245 27.325);
62 | --border: oklch(0.92 0.004 286.32);
63 | --input: oklch(0.92 0.004 286.32);
64 | --ring: oklch(0.623 0.214 259.815);
65 | --chart-1: oklch(0.646 0.222 41.116);
66 | --chart-2: oklch(0.6 0.118 184.704);
67 | --chart-3: oklch(0.398 0.07 227.392);
68 | --chart-4: oklch(0.828 0.189 84.429);
69 | --chart-5: oklch(0.769 0.188 70.08);
70 | --sidebar: oklch(0.985 0 0);
71 | --sidebar-foreground: oklch(0.141 0.005 285.823);
72 | --sidebar-primary: oklch(0.623 0.214 259.815);
73 | --sidebar-primary-foreground: oklch(0.97 0.014 254.604);
74 | --sidebar-accent: oklch(0.967 0.001 286.375);
75 | --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
76 | --sidebar-border: oklch(0.92 0.004 286.32);
77 | --sidebar-ring: oklch(0.623 0.214 259.815);
78 | }
79 |
80 | .dark {
81 | --background: oklch(0.141 0.005 285.823);
82 | --foreground: oklch(0.985 0 0);
83 | --card: oklch(0.21 0.006 285.885);
84 | --card-foreground: oklch(0.985 0 0);
85 | --popover: oklch(0.21 0.006 285.885);
86 | --popover-foreground: oklch(0.985 0 0);
87 | --primary: oklch(0.546 0.245 262.881);
88 | --primary-foreground: oklch(0.379 0.146 265.522);
89 | --secondary: oklch(0.274 0.006 286.033);
90 | --secondary-foreground: oklch(0.985 0 0);
91 | --muted: oklch(0.274 0.006 286.033);
92 | --muted-foreground: oklch(0.705 0.015 286.067);
93 | --accent: oklch(0.274 0.006 286.033);
94 | --accent-foreground: oklch(0.985 0 0);
95 | --destructive: oklch(0.704 0.191 22.216);
96 | --border: oklch(1 0 0 / 10%);
97 | --input: oklch(1 0 0 / 15%);
98 | --ring: oklch(0.488 0.243 264.376);
99 | --chart-1: oklch(0.488 0.243 264.376);
100 | --chart-2: oklch(0.696 0.17 162.48);
101 | --chart-3: oklch(0.769 0.188 70.08);
102 | --chart-4: oklch(0.627 0.265 303.9);
103 | --chart-5: oklch(0.645 0.246 16.439);
104 | --sidebar: oklch(0.21 0.006 285.885);
105 | --sidebar-foreground: oklch(0.985 0 0);
106 | --sidebar-primary: oklch(0.546 0.245 262.881);
107 | --sidebar-primary-foreground: oklch(0.379 0.146 265.522);
108 | --sidebar-accent: oklch(0.274 0.006 286.033);
109 | --sidebar-accent-foreground: oklch(0.985 0 0);
110 | --sidebar-border: oklch(1 0 0 / 10%);
111 | --sidebar-ring: oklch(0.488 0.243 264.376);
112 | }
113 |
114 | @layer base {
115 | * {
116 | @apply border-border outline-ring/50;
117 | }
118 | body {
119 | @apply bg-background text-foreground;
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/src/app/(protected)/doctors/_components/doctor-card.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | CalendarIcon,
5 | ClockIcon,
6 | DollarSignIcon,
7 | TrashIcon,
8 | } from "lucide-react";
9 | import { useAction } from "next-safe-action/hooks";
10 | import { useState } from "react";
11 | import { toast } from "sonner";
12 |
13 | import { deleteDoctor } from "@/actions/delete-doctor";
14 | import {
15 | AlertDialog,
16 | AlertDialogAction,
17 | AlertDialogCancel,
18 | AlertDialogContent,
19 | AlertDialogDescription,
20 | AlertDialogFooter,
21 | AlertDialogHeader,
22 | AlertDialogTitle,
23 | AlertDialogTrigger,
24 | } from "@/components/ui/alert-dialog";
25 | import { Avatar, AvatarFallback } from "@/components/ui/avatar";
26 | import { Badge } from "@/components/ui/badge";
27 | import { Button } from "@/components/ui/button";
28 | import {
29 | Card,
30 | CardContent,
31 | CardFooter,
32 | CardHeader,
33 | } from "@/components/ui/card";
34 | import { Dialog, DialogTrigger } from "@/components/ui/dialog";
35 | import { Separator } from "@/components/ui/separator";
36 | import { doctorsTable } from "@/db/schema";
37 | import { formatCurrencyInCents } from "@/helpers/currency";
38 |
39 | import { getAvailability } from "../_helpers/availability";
40 | import UpsertDoctorForm from "./upsert-doctor-form";
41 |
42 | interface DoctorCardProps {
43 | doctor: typeof doctorsTable.$inferSelect;
44 | }
45 |
46 | const DoctorCard = ({ doctor }: DoctorCardProps) => {
47 | const [isUpsertDoctorDialogOpen, setIsUpsertDoctorDialogOpen] =
48 | useState(false);
49 | const deleteDoctorAction = useAction(deleteDoctor, {
50 | onSuccess: () => {
51 | toast.success("Médico deletado com sucesso.");
52 | },
53 | onError: () => {
54 | toast.error("Erro ao deletar médico.");
55 | },
56 | });
57 | const handleDeleteDoctorClick = () => {
58 | if (!doctor) return;
59 | deleteDoctorAction.execute({ id: doctor.id });
60 | };
61 |
62 | const doctorInitials = doctor.name
63 | .split(" ")
64 | .map((name) => name[0])
65 | .join("");
66 | const availability = getAvailability(doctor);
67 |
68 | return (
69 |
70 |
71 |
72 |
73 | {doctorInitials}
74 |
75 |
76 |
{doctor.name}
77 |
{doctor.specialty}
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | {availability.from.format("dddd")} a {availability.to.format("dddd")}
86 |
87 |
88 |
89 | {availability.from.format("HH:mm")} as{" "}
90 | {availability.to.format("HH:mm")}
91 |
92 |
93 |
94 | {formatCurrencyInCents(doctor.appointmentPriceInCents)}
95 |
96 |
97 |
98 |
99 |
103 |
104 | Ver detalhes
105 |
106 | setIsUpsertDoctorDialogOpen(false)}
113 | isOpen={isUpsertDoctorDialogOpen}
114 | />
115 |
116 |
117 |
118 |
119 |
120 | Deletar médico
121 |
122 |
123 |
124 |
125 |
126 | Tem certeza que deseja deletar esse médico?
127 |
128 |
129 | Essa ação não pode ser revertida. Isso irá deletar o médico e
130 | todas as consultas agendadas.
131 |
132 |
133 |
134 | Cancelar
135 |
136 | Deletar
137 |
138 |
139 |
140 |
141 |
142 |
143 | );
144 | };
145 |
146 | export default DoctorCard;
147 |
--------------------------------------------------------------------------------
/src/app/(protected)/dashboard/_components/appointments-chart.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import "dayjs/locale/pt-br";
4 |
5 | import dayjs from "dayjs";
6 |
7 | dayjs.locale("pt-br");
8 | import { DollarSign } from "lucide-react";
9 | import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
10 |
11 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
12 | import {
13 | type ChartConfig,
14 | ChartContainer,
15 | ChartTooltip,
16 | ChartTooltipContent,
17 | } from "@/components/ui/chart";
18 | import { formatCurrencyInCents } from "@/helpers/currency";
19 |
20 | interface DailyAppointment {
21 | date: string;
22 | appointments: number;
23 | revenue: number | null;
24 | }
25 |
26 | interface AppointmentsChartProps {
27 | dailyAppointmentsData: DailyAppointment[];
28 | }
29 |
30 | const AppointmentsChart = ({
31 | dailyAppointmentsData,
32 | }: AppointmentsChartProps) => {
33 | // Gerar 21 dias: 10 antes + hoje + 10 depois
34 | const chartDays = Array.from({ length: 21 }).map((_, i) =>
35 | dayjs()
36 | .subtract(10 - i, "days")
37 | .format("YYYY-MM-DD"),
38 | );
39 |
40 | const chartData = chartDays.map((date) => {
41 | const dataForDay = dailyAppointmentsData.find((item) => item.date === date);
42 | return {
43 | date: dayjs(date).format("DD/MM"),
44 | fullDate: date,
45 | appointments: dataForDay?.appointments || 0,
46 | revenue: Number(dataForDay?.revenue || 0),
47 | };
48 | });
49 |
50 | const chartConfig = {
51 | appointments: {
52 | label: "Agendamentos",
53 | color: "#0B68F7",
54 | },
55 | revenue: {
56 | label: "Faturamento",
57 | color: "#10B981",
58 | },
59 | } satisfies ChartConfig;
60 |
61 | return (
62 |
63 |
64 |
65 | Agendamentos e Faturamento
66 |
67 |
68 |
69 |
73 |
74 |
80 |
86 | formatCurrencyInCents(value)}
93 | />
94 | {
98 | if (name === "revenue") {
99 | return (
100 | <>
101 |
102 |
103 | Faturamento:
104 |
105 |
106 | {formatCurrencyInCents(Number(value))}
107 |
108 | >
109 | );
110 | }
111 | return (
112 | <>
113 |
114 |
115 | Agendamentos:
116 |
117 | {value}
118 | >
119 | );
120 | }}
121 | labelFormatter={(label, payload) => {
122 | if (payload && payload[0]) {
123 | return dayjs(payload[0].payload?.fullDate).format(
124 | "DD/MM/YYYY (dddd)",
125 | );
126 | }
127 | return label;
128 | }}
129 | />
130 | }
131 | />
132 |
141 |
150 |
151 |
152 |
153 |
154 | );
155 | };
156 |
157 | export default AppointmentsChart;
158 |
--------------------------------------------------------------------------------
/src/app/authentication/components/login-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import { Loader2 } from "lucide-react";
5 | import { useRouter } from "next/navigation";
6 | import { useForm } from "react-hook-form";
7 | import { toast } from "sonner";
8 | import { z } from "zod";
9 |
10 | import { Button } from "@/components/ui/button";
11 | import {
12 | Card,
13 | CardContent,
14 | CardDescription,
15 | CardFooter,
16 | CardHeader,
17 | CardTitle,
18 | } from "@/components/ui/card";
19 | import { FormControl, FormMessage } from "@/components/ui/form";
20 | import { FormItem, FormLabel } from "@/components/ui/form";
21 | import { Form, FormField } from "@/components/ui/form";
22 | import { Input } from "@/components/ui/input";
23 | import { authClient } from "@/lib/auth-client";
24 |
25 | const loginSchema = z.object({
26 | email: z
27 | .string()
28 | .trim()
29 | .min(1, { message: "E-mail é obrigatório" })
30 | .email({ message: "E-mail inválido" }),
31 | password: z
32 | .string()
33 | .trim()
34 | .min(8, { message: "A senha deve ter pelo menos 8 caracteres" }),
35 | });
36 |
37 | const LoginForm = () => {
38 | const router = useRouter();
39 | const form = useForm>({
40 | resolver: zodResolver(loginSchema),
41 | defaultValues: {
42 | email: "",
43 | password: "",
44 | },
45 | });
46 |
47 | const handleSubmit = async (values: z.infer) => {
48 | await authClient.signIn.email(
49 | {
50 | email: values.email,
51 | password: values.password,
52 | },
53 | {
54 | onSuccess: () => {
55 | router.push("/dashboard");
56 | },
57 | onError: () => {
58 | toast.error("E-mail ou senha inválidos.");
59 | },
60 | },
61 | );
62 | };
63 |
64 | const handleGoogleLogin = async () => {
65 | await authClient.signIn.social({
66 | provider: "google",
67 | callbackURL: "/dashboard",
68 | scopes: ["email", "profile"],
69 | });
70 | };
71 |
72 | return (
73 |
74 |
154 |
155 |
156 | );
157 | };
158 |
159 | export default LoginForm;
160 |
--------------------------------------------------------------------------------
/src/app/(protected)/patients/_components/upsert-patient-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import { useAction } from "next-safe-action/hooks";
5 | import { useEffect } from "react";
6 | import { useForm } from "react-hook-form";
7 | import { PatternFormat } from "react-number-format";
8 | import { toast } from "sonner";
9 | import { z } from "zod";
10 |
11 | import { upsertPatient } from "@/actions/upsert-patient";
12 | import { Button } from "@/components/ui/button";
13 | import {
14 | DialogContent,
15 | DialogDescription,
16 | DialogFooter,
17 | DialogHeader,
18 | DialogTitle,
19 | } from "@/components/ui/dialog";
20 | import {
21 | Form,
22 | FormControl,
23 | FormField,
24 | FormItem,
25 | FormLabel,
26 | FormMessage,
27 | } from "@/components/ui/form";
28 | import { Input } from "@/components/ui/input";
29 | import {
30 | Select,
31 | SelectContent,
32 | SelectItem,
33 | SelectTrigger,
34 | SelectValue,
35 | } from "@/components/ui/select";
36 | import { patientsTable } from "@/db/schema";
37 |
38 | const formSchema = z.object({
39 | name: z.string().trim().min(1, {
40 | message: "Nome é obrigatório.",
41 | }),
42 | email: z.string().email({
43 | message: "Email inválido.",
44 | }),
45 | phoneNumber: z.string().trim().min(1, {
46 | message: "Número de telefone é obrigatório.",
47 | }),
48 | sex: z.enum(["male", "female"], {
49 | required_error: "Sexo é obrigatório.",
50 | }),
51 | });
52 |
53 | interface UpsertPatientFormProps {
54 | isOpen: boolean;
55 | patient?: typeof patientsTable.$inferSelect;
56 | onSuccess?: () => void;
57 | }
58 |
59 | const UpsertPatientForm = ({
60 | patient,
61 | onSuccess,
62 | isOpen,
63 | }: UpsertPatientFormProps) => {
64 | const form = useForm>({
65 | shouldUnregister: true,
66 | resolver: zodResolver(formSchema),
67 | defaultValues: {
68 | name: patient?.name ?? "",
69 | email: patient?.email ?? "",
70 | phoneNumber: patient?.phoneNumber ?? "",
71 | sex: patient?.sex ?? undefined,
72 | },
73 | });
74 |
75 | useEffect(() => {
76 | if (isOpen) {
77 | form.reset(patient);
78 | }
79 | }, [isOpen, form, patient]);
80 |
81 | const upsertPatientAction = useAction(upsertPatient, {
82 | onSuccess: () => {
83 | toast.success("Paciente salvo com sucesso.");
84 | onSuccess?.();
85 | },
86 | onError: () => {
87 | toast.error("Erro ao salvar paciente.");
88 | },
89 | });
90 |
91 | const onSubmit = (values: z.infer) => {
92 | upsertPatientAction.execute({
93 | ...values,
94 | id: patient?.id,
95 | });
96 | };
97 |
98 | return (
99 |
100 |
101 |
102 | {patient ? patient.name : "Adicionar paciente"}
103 |
104 |
105 | {patient
106 | ? "Edite as informações desse paciente."
107 | : "Adicione um novo paciente."}
108 |
109 |
110 |
201 |
202 |
203 | );
204 | };
205 |
206 | export default UpsertPatientForm;
207 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as SelectPrimitive from "@radix-ui/react-select"
4 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
5 | import * as React from "react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Select({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function SelectGroup({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function SelectValue({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function SelectTrigger({
28 | className,
29 | size = "default",
30 | children,
31 | ...props
32 | }: React.ComponentProps & {
33 | size?: "sm" | "default"
34 | }) {
35 | return (
36 |
45 | {children}
46 |
47 |
48 |
49 |
50 | )
51 | }
52 |
53 | function SelectContent({
54 | className,
55 | children,
56 | position = "popper",
57 | ...props
58 | }: React.ComponentProps) {
59 | return (
60 |
61 |
72 |
73 |
80 | {children}
81 |
82 |
83 |
84 |
85 | )
86 | }
87 |
88 | function SelectLabel({
89 | className,
90 | ...props
91 | }: React.ComponentProps) {
92 | return (
93 |
98 | )
99 | }
100 |
101 | function SelectItem({
102 | className,
103 | children,
104 | ...props
105 | }: React.ComponentProps) {
106 | return (
107 |
115 |
116 |
117 |
118 |
119 |
120 | {children}
121 |
122 | )
123 | }
124 |
125 | function SelectSeparator({
126 | className,
127 | ...props
128 | }: React.ComponentProps) {
129 | return (
130 |
135 | )
136 | }
137 |
138 | function SelectScrollUpButton({
139 | className,
140 | ...props
141 | }: React.ComponentProps) {
142 | return (
143 |
151 |
152 |
153 | )
154 | }
155 |
156 | function SelectScrollDownButton({
157 | className,
158 | ...props
159 | }: React.ComponentProps) {
160 | return (
161 |
169 |
170 |
171 | )
172 | }
173 |
174 | export {
175 | Select,
176 | SelectContent,
177 | SelectGroup,
178 | SelectItem,
179 | SelectLabel,
180 | SelectScrollDownButton,
181 | SelectScrollUpButton,
182 | SelectSeparator,
183 | SelectTrigger,
184 | SelectValue,
185 | }
186 |
--------------------------------------------------------------------------------
/src/db/schema.ts:
--------------------------------------------------------------------------------
1 | import { relations } from "drizzle-orm";
2 | import {
3 | boolean,
4 | integer,
5 | pgEnum,
6 | pgTable,
7 | text,
8 | time,
9 | timestamp,
10 | uuid,
11 | } from "drizzle-orm/pg-core";
12 |
13 | export const usersTable = pgTable("users", {
14 | id: text("id").primaryKey(),
15 | name: text("name").notNull(),
16 | email: text("email").notNull().unique(),
17 | emailVerified: boolean("email_verified").notNull(),
18 | image: text("image"),
19 | stripeCustomerId: text("stripe_customer_id"),
20 | stripeSubscriptionId: text("stripe_subscription_id"),
21 | plan: text("plan"),
22 | createdAt: timestamp("created_at").notNull(),
23 | updatedAt: timestamp("updated_at").notNull(),
24 | });
25 |
26 | export const usersTableRelations = relations(usersTable, ({ many }) => ({
27 | usersToClinics: many(usersToClinicsTable),
28 | }));
29 |
30 | export const sessionsTable = pgTable("sessions", {
31 | id: text("id").primaryKey(),
32 | expiresAt: timestamp("expires_at").notNull(),
33 | token: text("token").notNull().unique(),
34 | createdAt: timestamp("created_at").notNull(),
35 | updatedAt: timestamp("updated_at").notNull(),
36 | ipAddress: text("ip_address"),
37 | userAgent: text("user_agent"),
38 | userId: text("user_id")
39 | .notNull()
40 | .references(() => usersTable.id, { onDelete: "cascade" }),
41 | });
42 |
43 | export const accountsTable = pgTable("accounts", {
44 | id: text("id").primaryKey(),
45 | accountId: text("account_id").notNull(),
46 | providerId: text("provider_id").notNull(),
47 | userId: text("user_id")
48 | .notNull()
49 | .references(() => usersTable.id, { onDelete: "cascade" }),
50 | accessToken: text("access_token"),
51 | refreshToken: text("refresh_token"),
52 | idToken: text("id_token"),
53 | accessTokenExpiresAt: timestamp("access_token_expires_at"),
54 | refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
55 | scope: text("scope"),
56 | password: text("password"),
57 | createdAt: timestamp("created_at").notNull(),
58 | updatedAt: timestamp("updated_at").notNull(),
59 | });
60 |
61 | export const verificationsTable = pgTable("verifications", {
62 | id: text("id").primaryKey(),
63 | identifier: text("identifier").notNull(),
64 | value: text("value").notNull(),
65 | expiresAt: timestamp("expires_at").notNull(),
66 | createdAt: timestamp("created_at"),
67 | updatedAt: timestamp("updated_at"),
68 | });
69 |
70 | export const clinicsTable = pgTable("clinics", {
71 | id: uuid("id").defaultRandom().primaryKey(),
72 | name: text("name").notNull(),
73 | createdAt: timestamp("created_at").defaultNow().notNull(),
74 | updatedAt: timestamp("updated_at")
75 | .defaultNow()
76 | .$onUpdate(() => new Date()),
77 | });
78 |
79 | export const usersToClinicsTable = pgTable("users_to_clinics", {
80 | userId: text("user_id")
81 | .notNull()
82 | .references(() => usersTable.id, { onDelete: "cascade" }),
83 | clinicId: uuid("clinic_id")
84 | .notNull()
85 | .references(() => clinicsTable.id, { onDelete: "cascade" }),
86 | createdAt: timestamp("created_at").defaultNow().notNull(),
87 | updatedAt: timestamp("updated_at")
88 | .defaultNow()
89 | .$onUpdate(() => new Date()),
90 | });
91 |
92 | export const usersToClinicsTableRelations = relations(
93 | usersToClinicsTable,
94 | ({ one }) => ({
95 | user: one(usersTable, {
96 | fields: [usersToClinicsTable.userId],
97 | references: [usersTable.id],
98 | }),
99 | clinic: one(clinicsTable, {
100 | fields: [usersToClinicsTable.clinicId],
101 | references: [clinicsTable.id],
102 | }),
103 | }),
104 | );
105 |
106 | export const clinicsTableRelations = relations(clinicsTable, ({ many }) => ({
107 | doctors: many(doctorsTable),
108 | patients: many(patientsTable),
109 | appointments: many(appointmentsTable),
110 | usersToClinics: many(usersToClinicsTable),
111 | }));
112 |
113 | export const doctorsTable = pgTable("doctors", {
114 | id: uuid("id").defaultRandom().primaryKey(),
115 | clinicId: uuid("clinic_id")
116 | .notNull()
117 | .references(() => clinicsTable.id, { onDelete: "cascade" }),
118 | name: text("name").notNull(),
119 | avatarImageUrl: text("avatar_image_url"),
120 | // 1 - Monday, 2 - Tuesday, 3 - Wednesday, 4 - Thursday, 5 - Friday, 6 - Saturday, 0 - Sunday
121 | availableFromWeekDay: integer("available_from_week_day").notNull(),
122 | availableToWeekDay: integer("available_to_week_day").notNull(),
123 | availableFromTime: time("available_from_time").notNull(),
124 | availableToTime: time("available_to_time").notNull(),
125 | specialty: text("specialty").notNull(),
126 | appointmentPriceInCents: integer("appointment_price_in_cents").notNull(),
127 | createdAt: timestamp("created_at").defaultNow().notNull(),
128 | updatedAt: timestamp("updated_at")
129 | .defaultNow()
130 | .$onUpdate(() => new Date()),
131 | });
132 |
133 | export const doctorsTableRelations = relations(
134 | doctorsTable,
135 | ({ many, one }) => ({
136 | clinic: one(clinicsTable, {
137 | fields: [doctorsTable.clinicId],
138 | references: [clinicsTable.id],
139 | }),
140 | appointments: many(appointmentsTable),
141 | }),
142 | );
143 |
144 | export const patientSexEnum = pgEnum("patient_sex", ["male", "female"]);
145 |
146 | export const patientsTable = pgTable("patients", {
147 | id: uuid("id").defaultRandom().primaryKey(),
148 | clinicId: uuid("clinic_id")
149 | .notNull()
150 | .references(() => clinicsTable.id, { onDelete: "cascade" }),
151 | name: text("name").notNull(),
152 | email: text("email").notNull(),
153 | phoneNumber: text("phone_number").notNull(),
154 | createdAt: timestamp("created_at").defaultNow().notNull(),
155 | sex: patientSexEnum("sex").notNull(),
156 | updatedAt: timestamp("updated_at")
157 | .defaultNow()
158 | .$onUpdate(() => new Date()),
159 | });
160 |
161 | export const patientsTableRelations = relations(
162 | patientsTable,
163 | ({ one, many }) => ({
164 | clinic: one(clinicsTable, {
165 | fields: [patientsTable.clinicId],
166 | references: [clinicsTable.id],
167 | }),
168 | appointments: many(appointmentsTable),
169 | }),
170 | );
171 |
172 | export const appointmentsTable = pgTable("appointments", {
173 | id: uuid("id").defaultRandom().primaryKey(),
174 | date: timestamp("date").notNull(),
175 | appointmentPriceInCents: integer("appointment_price_in_cents").notNull(),
176 | clinicId: uuid("clinic_id")
177 | .notNull()
178 | .references(() => clinicsTable.id, { onDelete: "cascade" }),
179 | patientId: uuid("patient_id")
180 | .notNull()
181 | .references(() => patientsTable.id, { onDelete: "cascade" }),
182 | doctorId: uuid("doctor_id")
183 | .notNull()
184 | .references(() => doctorsTable.id, { onDelete: "cascade" }),
185 | createdAt: timestamp("created_at").defaultNow().notNull(),
186 | updatedAt: timestamp("updated_at")
187 | .defaultNow()
188 | .$onUpdate(() => new Date()),
189 | });
190 |
191 | export const appointmentsTableRelations = relations(
192 | appointmentsTable,
193 | ({ one }) => ({
194 | clinic: one(clinicsTable, {
195 | fields: [appointmentsTable.clinicId],
196 | references: [clinicsTable.id],
197 | }),
198 | patient: one(patientsTable, {
199 | fields: [appointmentsTable.patientId],
200 | references: [patientsTable.id],
201 | }),
202 | doctor: one(doctorsTable, {
203 | fields: [appointmentsTable.doctorId],
204 | references: [doctorsTable.id],
205 | }),
206 | }),
207 | );
208 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
4 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
5 | import * as React from "react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function DropdownMenu({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function DropdownMenuPortal({
16 | ...props
17 | }: React.ComponentProps) {
18 | return (
19 |
20 | )
21 | }
22 |
23 | function DropdownMenuTrigger({
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
31 | )
32 | }
33 |
34 | function DropdownMenuContent({
35 | className,
36 | sideOffset = 4,
37 | ...props
38 | }: React.ComponentProps) {
39 | return (
40 |
41 |
50 |
51 | )
52 | }
53 |
54 | function DropdownMenuGroup({
55 | ...props
56 | }: React.ComponentProps) {
57 | return (
58 |
59 | )
60 | }
61 |
62 | function DropdownMenuItem({
63 | className,
64 | inset,
65 | variant = "default",
66 | ...props
67 | }: React.ComponentProps & {
68 | inset?: boolean
69 | variant?: "default" | "destructive"
70 | }) {
71 | return (
72 |
82 | )
83 | }
84 |
85 | function DropdownMenuCheckboxItem({
86 | className,
87 | children,
88 | checked,
89 | ...props
90 | }: React.ComponentProps) {
91 | return (
92 |
101 |
102 |
103 |
104 |
105 |
106 | {children}
107 |
108 | )
109 | }
110 |
111 | function DropdownMenuRadioGroup({
112 | ...props
113 | }: React.ComponentProps) {
114 | return (
115 |
119 | )
120 | }
121 |
122 | function DropdownMenuRadioItem({
123 | className,
124 | children,
125 | ...props
126 | }: React.ComponentProps) {
127 | return (
128 |
136 |
137 |
138 |
139 |
140 |
141 | {children}
142 |
143 | )
144 | }
145 |
146 | function DropdownMenuLabel({
147 | className,
148 | inset,
149 | ...props
150 | }: React.ComponentProps & {
151 | inset?: boolean
152 | }) {
153 | return (
154 |
163 | )
164 | }
165 |
166 | function DropdownMenuSeparator({
167 | className,
168 | ...props
169 | }: React.ComponentProps) {
170 | return (
171 |
176 | )
177 | }
178 |
179 | function DropdownMenuShortcut({
180 | className,
181 | ...props
182 | }: React.ComponentProps<"span">) {
183 | return (
184 |
192 | )
193 | }
194 |
195 | function DropdownMenuSub({
196 | ...props
197 | }: React.ComponentProps) {
198 | return
199 | }
200 |
201 | function DropdownMenuSubTrigger({
202 | className,
203 | inset,
204 | children,
205 | ...props
206 | }: React.ComponentProps & {
207 | inset?: boolean
208 | }) {
209 | return (
210 |
219 | {children}
220 |
221 |
222 | )
223 | }
224 |
225 | function DropdownMenuSubContent({
226 | className,
227 | ...props
228 | }: React.ComponentProps) {
229 | return (
230 |
238 | )
239 | }
240 |
241 | export {
242 | DropdownMenu,
243 | DropdownMenuCheckboxItem,
244 | DropdownMenuContent,
245 | DropdownMenuGroup,
246 | DropdownMenuItem,
247 | DropdownMenuLabel,
248 | DropdownMenuPortal,
249 | DropdownMenuRadioGroup,
250 | DropdownMenuRadioItem,
251 | DropdownMenuSeparator,
252 | DropdownMenuShortcut,
253 | DropdownMenuSub,
254 | DropdownMenuSubContent,
255 | DropdownMenuSubTrigger,
256 | DropdownMenuTrigger,
257 | }
258 |
--------------------------------------------------------------------------------
/src/components/ui/chart.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as RechartsPrimitive from "recharts"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | // Format: { THEME_NAME: CSS_SELECTOR }
9 | const THEMES = { light: "", dark: ".dark" } as const
10 |
11 | export type ChartConfig = {
12 | [k in string]: {
13 | label?: React.ReactNode
14 | icon?: React.ComponentType
15 | } & (
16 | | { color?: string; theme?: never }
17 | | { color?: never; theme: Record }
18 | )
19 | }
20 |
21 | type ChartContextProps = {
22 | config: ChartConfig
23 | }
24 |
25 | const ChartContext = React.createContext(null)
26 |
27 | function useChart() {
28 | const context = React.useContext(ChartContext)
29 |
30 | if (!context) {
31 | throw new Error("useChart must be used within a ")
32 | }
33 |
34 | return context
35 | }
36 |
37 | function ChartContainer({
38 | id,
39 | className,
40 | children,
41 | config,
42 | ...props
43 | }: React.ComponentProps<"div"> & {
44 | config: ChartConfig
45 | children: React.ComponentProps<
46 | typeof RechartsPrimitive.ResponsiveContainer
47 | >["children"]
48 | }) {
49 | const uniqueId = React.useId()
50 | const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
51 |
52 | return (
53 |
54 |
63 |
64 |
65 | {children}
66 |
67 |
68 |
69 | )
70 | }
71 |
72 | const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
73 | const colorConfig = Object.entries(config).filter(
74 | ([, config]) => config.theme || config.color
75 | )
76 |
77 | if (!colorConfig.length) {
78 | return null
79 | }
80 |
81 | return (
82 |