Primer commit

This commit is contained in:
2026-05-30 12:01:25 -05:00
parent 46fd5d2afb
commit 40986b0359
17 changed files with 2251 additions and 152 deletions
+1390 -30
View File
File diff suppressed because it is too large Load Diff
+21 -1
View File
@@ -10,15 +10,35 @@
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.0", "@base-ui/react": "^1.4.0",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/resource-timegrid": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@hookform/resolvers": "^5.2.2",
"@playwright/test": "^1.59.1",
"@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.103.2",
"@vitest/coverage-v8": "^4.1.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0",
"fullcalendar": "^6.1.20",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "16.2.3", "next": "16.2.3",
"next-themes": "^0.4.6",
"playwright": "^1.59.1",
"react": "19.2.4", "react": "19.2.4",
"react-day-picker": "^9.14.0",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.72.1",
"shadcn": "^4.2.0", "shadcn": "^4.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0",
"vitest": "^4.1.4",
"zod": "^4.3.6",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { createAdminClient } from '@/utils/supabase/server';
const RequestSchema = z.object({
query: z.string().min(1),
paciente_id: z.string().uuid().optional(),
medico_id: z.string().uuid(),
target_date: z.string().date(),
});
export async function POST(request: Request) {
try {
const body = await request.json();
const data = RequestSchema.parse(body);
const supabase = createAdminClient();
// Call securely the custom RPC passing date params avoiding auth issues
const { data: availability, error } = await supabase.rpc('check_availability', {
medico_id_param: data.medico_id,
date_param: data.target_date
});
if (error) {
console.error('Supabase query error:', error);
return NextResponse.json({ error: "No se pudo comprobar la disponibilidad." }, { status: 500 });
}
return NextResponse.json({
message: "Procesado correctamente por AI agent",
available_slots: availability || []
}, { status: 200 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: "Datos de entrada inválidos", details: (error as any).errors }, { status: 400 });
}
return NextResponse.json({ error: "Error interno del servidor" }, { status: 500 });
}
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Calendar } from "@/components/ui/calendar";
export default function DashboardPage() {
const [aiEnabled, setAiEnabled] = useState(true);
const [date, setDate] = useState<Date | undefined>(new Date());
return (
<div className="container mx-auto p-6 max-w-6xl space-y-6">
<header className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-heading font-bold text-primary">EnFlow Clinical Dashboard</h1>
<p className="text-muted-foreground mt-1 text-sm">Panel de control omnicanal</p>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Omnichannel Console Placeholder */}
<Card className="col-span-1 md:col-span-2">
<CardHeader>
<CardTitle className="font-heading">Consola Omnicanal</CardTitle>
<CardDescription>Interacciones recientes (WhatsApp, Web, IG)</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] bg-muted/30 rounded-18px flex items-center justify-center border border-border p-4">
<p className="text-sm text-placeholder text-muted-foreground">No hay interacciones pendientes.</p>
</div>
</CardContent>
<CardFooter className="flex justify-between items-center">
<span className="text-sm font-medium">Estado del Agente AI</span>
<div className="flex items-center space-x-2">
<span className="text-sm text-muted-foreground">
{aiEnabled ? 'Automatización Activa' : 'Desactivado (Manual Override)'}
</span>
<Switch checked={aiEnabled} onCheckedChange={setAiEnabled} />
</div>
</CardFooter>
</Card>
{/* Calendar Management Placeholder */}
<Card>
<CardHeader>
<CardTitle className="font-heading">Agenda Clínica</CardTitle>
<CardDescription>Gestión de disponibilidad</CardDescription>
</CardHeader>
<CardContent className="flex justify-center flex-col gap-4">
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="rounded-md border mx-auto"
/>
<Button className="w-full mt-4 bg-primary text-primary-foreground hover:opacity-90">
Sincronizar Supabase
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
+53 -53
View File
@@ -7,9 +7,9 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-sans); --font-sans: var(--font-inter);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans); --font-heading: var(--font-space-grotesk);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -49,72 +49,72 @@
} }
:root { :root {
--background: oklch(1 0 0); --background: #ffffff;
--foreground: oklch(0.145 0 0); --foreground: #0d141d;
--card: oklch(1 0 0); --card: #ffffff;
--card-foreground: oklch(0.145 0 0); --card-foreground: #0d141d;
--popover: oklch(1 0 0); --popover: #ffffff;
--popover-foreground: oklch(0.145 0 0); --popover-foreground: #0d141d;
--primary: oklch(0.205 0 0); --primary: #00daf3;
--primary-foreground: oklch(0.985 0 0); --primary-foreground: #0d141d;
--secondary: oklch(0.97 0 0); --secondary: #ecbaba;
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: #0d141d;
--muted: oklch(0.97 0 0); --muted: #f4f4f5;
--muted-foreground: oklch(0.556 0 0); --muted-foreground: #71717a;
--accent: oklch(0.97 0 0); --accent: #edb1ff;
--accent-foreground: oklch(0.205 0 0); --accent-foreground: #0d141d;
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: #e2e8f0;
--input: oklch(0.922 0 0); --input: #e2e8f0;
--ring: oklch(0.708 0 0); --ring: #00daf3;
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.269 0 0);
--radius: 0.625rem; --radius: 18px;
--sidebar: oklch(0.985 0 0); --sidebar: #ffffff;
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: #0d141d;
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: #00daf3;
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: #ffffff;
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: #f4f4f5;
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: #0d141d;
--sidebar-border: oklch(0.922 0 0); --sidebar-border: #e2e8f0;
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: #00daf3;
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: #0d141d;
--foreground: oklch(0.985 0 0); --foreground: #ffffff;
--card: oklch(0.205 0 0); --card: #141c26;
--card-foreground: oklch(0.985 0 0); --card-foreground: #ffffff;
--popover: oklch(0.205 0 0); --popover: #141c26;
--popover-foreground: oklch(0.985 0 0); --popover-foreground: #ffffff;
--primary: oklch(0.922 0 0); --primary: #00daf3;
--primary-foreground: oklch(0.205 0 0); --primary-foreground: #000000;
--secondary: oklch(0.269 0 0); --secondary: #ecbaba;
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: #0d141d;
--muted: oklch(0.269 0 0); --muted: #1e293b;
--muted-foreground: oklch(0.708 0 0); --muted-foreground: #94a3b8;
--accent: oklch(0.269 0 0); --accent: #edb1ff;
--accent-foreground: oklch(0.985 0 0); --accent-foreground: #0d141d;
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: #2a2a2a;
--input: oklch(1 0 0 / 15%); --input: #2a2a2a;
--ring: oklch(0.556 0 0); --ring: #00daf3;
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0); --sidebar: #0d141d;
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: #ffffff;
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: #00daf3;
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: #000000;
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: #1e293b;
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: #ffffff;
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: #2a2a2a;
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: #00daf3;
} }
@layer base { @layer base {
+6 -6
View File
@@ -1,14 +1,14 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Space_Grotesk, Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const spaceGrotesk = Space_Grotesk({
variable: "--font-geist-sans", variable: "--font-space-grotesk",
subsets: ["latin"], subsets: ["latin"],
}); });
const geistMono = Geist_Mono({ const inter = Inter({
variable: "--font-geist-mono", variable: "--font-inter",
subsets: ["latin"], subsets: ["latin"],
}); });
@@ -25,7 +25,7 @@ export default function RootLayout({
return ( return (
<html <html
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${spaceGrotesk.variable} ${inter.variable} h-full antialiased font-sans`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">{children}</body>
</html> </html>
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { createBrowserClient } from "@supabase/ssr";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
setError(error.message);
setLoading(false);
} else {
router.push("/dashboard");
router.refresh();
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-black p-4">
<Card className="w-full max-w-md shadow-lg border-border">
<CardHeader className="space-y-2 text-center">
<CardTitle className="text-3xl font-heading font-bold text-primary tracking-tight">
EnFlow
</CardTitle>
<CardDescription className="text-base">
Ingresa al portal clínico omnicanal
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Correo Electrónico</label>
<input
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
id="email"
type="email"
placeholder="doctor@clinica.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Contraseña</label>
<input
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && (
<p className="text-sm font-medium text-destructive text-center">
{error}
</p>
)}
<Button
type="submit"
className="w-full font-medium"
disabled={loading}
>
{loading ? "Autenticando..." : "Ingresar"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+2 -62
View File
@@ -1,65 +1,5 @@
import Image from "next/image"; import { redirect } from 'next/navigation';
export default function Home() { export default function Home() {
return ( redirect('/dashboard');
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
} }
+221
View File
@@ -0,0 +1,221 @@
"use client"
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+49
View File
@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+40
View File
@@ -0,0 +1,40 @@
import { z } from 'zod';
export const PacienteSchema = z.object({
id: z.string().uuid().optional(),
nombre: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
telefono: z.string().optional(),
email: z.string().email("Correo inválido").optional(),
notas_clinicas: z.string().optional(),
});
export const ProcedimientoSchema = z.object({
id: z.string().uuid().optional(),
nombre: z.string().min(2, "Procedimiento requerido"),
duracion_minutos: z.number().int().positive("Obligatorio"),
precio: z.number().positive().optional(),
});
export const CitaSchema = z.object({
id: z.string().uuid().optional(),
paciente_id: z.string().uuid(),
procedimiento_id: z.string().uuid().optional(),
start_time: z.string().datetime(),
end_time: z.string().datetime(),
status: z.enum(["agendada", "completada", "cancelada"]).default("agendada"),
medico_id: z.string().uuid(),
});
export const InteraccionSchema = z.object({
id: z.string().uuid().optional(),
paciente_id: z.string().uuid(),
plataforma: z.enum(["whatsapp", "instagram", "tiktok", "web"]),
request_payload: z.any(),
});
export const ChatStatusSchema = z.object({
id: z.string().uuid().optional(),
paciente_id: z.string().uuid(),
ia_activa: z.boolean().default(true),
last_updated: z.string().datetime().optional()
});
+19
View File
@@ -0,0 +1,19 @@
import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/middleware'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* Feel free to modify this pattern to include more paths.
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
+8
View File
@@ -0,0 +1,8 @@
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
+62
View File
@@ -0,0 +1,62 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(keysToSet) {
keysToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
keysToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// IMPORTANT: Avoid writing any logic between createServerClient and
// supabase.auth.getUser(). A simple mistake could make it very hard to debug
// issues with cross-site tracking.
const {
data: { user },
} = await supabase.auth.getUser()
// Protect dashboard routes
if (
!user &&
request.nextUrl.pathname.startsWith('/dashboard')
) {
// no user, potentially respond by redirecting the user to the login page
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// IMPORTANT: You *must* return the supabaseResponse object as it is. If you're
// creating a new response object with NextResponse.next() make sure to:
// 1. Pass the request in it, like so:
// const myNewResponse = NextResponse.next({ request })
// 2. Copy over the cookies, like so:
// myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())
// 3. Change the myNewResponse object to fit your needs, but avoid changing
// the cookies!
// 4. Finally:
// return myNewResponse
// If this is not done, you may be causing the browser and server to go out
// of sync and terminate the user's session prematurely!
return supabaseResponse
}
+46
View File
@@ -0,0 +1,46 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(keysToSet) {
try {
keysToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
)
}
// Utility for webhook/API environments that need elevated privileges
// Only to be used in secure backend routes without user context bleeding.
export function createAdminClient() {
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
cookies: {
getAll() {
return []
},
setAll() {}
}
}
)
}