Añadido Dockerfil docker-compose.yml

This commit is contained in:
2026-05-30 21:47:44 -05:00
parent 40986b0359
commit 5a563b214f
101 changed files with 10207 additions and 950 deletions
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { useEffect, useState, useMemo, useCallback } from "react";
import { createClient } from "@/utils/supabase/client";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { format } from "date-fns/format";
import { es } from "date-fns/locale/es";
import type { Slot } from "@/lib/types";
const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
confirmed: { label: "Confirmada", className: "bg-emerald-50 text-emerald-700 border-emerald-200" },
cancelled: { label: "Cancelada", className: "bg-red-50 text-red-700 border-red-200" },
completed: { label: "Completada", className: "bg-gray-50 text-gray-600 border-gray-200" },
reschedule: { label: "Reprogramar", className: "bg-yellow-50 text-yellow-700 border-yellow-200" },
};
/**
* Listado de todas las citas con actualización en tiempo real.
*/
export default function AppointmentsPage() {
const supabase = useMemo(() => createClient(), []);
const [appointments, setAppointments] = useState<Slot[]>([]);
const [isLoading, setIsLoading] = useState(true);
const load = useCallback(async () => {
try {
const { data, error } = await supabase
.from("slots")
.select("*, patient:patients(name, phone), procedure:procedures(name)")
.eq("block_type", "appointment")
.order("created_at", { ascending: false })
.limit(100);
if (error) {
console.error("Slots query error:", (error as Error).message);
}
if (data) {
setAppointments(data as Slot[]);
}
} catch (err) {
console.error("Slots fetch error:", (err as Error).message);
} finally {
setIsLoading(false);
}
}, [supabase]);
useEffect(() => {
void load();
const channel = supabase
.channel("appointments-list")
.on("postgres_changes", { event: "*", schema: "public", table: "slots", filter: "block_type=eq.appointment" }, () => {
void load();
})
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [load, supabase]);
return (
<div className="space-y-6">
<div>
<h2 className="font-heading font-bold text-2xl text-foreground">Citas</h2>
<p className="text-muted-foreground text-sm mt-1">
Historial completo de citas del consultorio
</p>
</div>
<Card className="border-border">
<CardContent className="pt-6">
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 rounded-[12px]" />
))}
</div>
) : appointments.length === 0 ? (
<div className="h-40 flex items-center justify-center text-muted-foreground text-sm">
No hay citas registradas.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<caption className="sr-only">
Listado de citas del consultorio
</caption>
<thead>
<tr className="text-left text-muted-foreground text-xs uppercase tracking-wide border-b border-border">
<th scope="col" className="pb-3 pr-4 font-medium">Paciente</th>
<th scope="col" className="pb-3 pr-4 font-medium">Procedimiento</th>
<th scope="col" className="pb-3 pr-4 font-medium">Canal</th>
<th scope="col" className="pb-3 pr-4 font-medium">Fecha y hora</th>
<th scope="col" className="pb-3 font-medium">Estado</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{appointments.map((apt) => {
const cfg = STATUS_CONFIG[apt.status] ?? STATUS_CONFIG.confirmed;
return (
<tr key={apt.id} className="hover:bg-muted/30 transition-colors">
<td className="py-3 pr-4">
<p className="font-medium text-foreground">
{apt.patient?.name ?? "—"}
</p>
<p className="text-xs text-muted-foreground">
{apt.patient?.phone ?? ""}
</p>
</td>
<td className="py-3 pr-4 text-foreground">
{apt.procedure?.name ?? "—"}
</td>
<td className="py-3 pr-4 text-muted-foreground capitalize">
{apt.channel}
</td>
<td className="py-3 pr-4 text-foreground">
{apt.start_at
? format(
new Date(apt.start_at),
"dd MMM yyyy · HH:mm",
{ locale: es }
)
: "—"}
</td>
<td className="py-3">
<Badge
variant="outline"
className={`text-[10px] ${cfg.className}`}
>
{cfg.label}
</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
+236
View File
@@ -0,0 +1,236 @@
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import dynamic from "next/dynamic";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import { DateSelectArg, EventClickArg, EventInput } from "@fullcalendar/core";
import esLocale from "@fullcalendar/core/locales/es-us";
import { createClient } from "@/utils/supabase/client";
import { useUIStore } from "@/lib/stores/uiStore";
import { SlotActionModal } from "@/components/calendar/SlotActionModal";
import { Loader2 } from "lucide-react";
import type { Slot, WorkingHours, Appointment } from "@/lib/types";
const FullCalendar = dynamic(() => import("@fullcalendar/react"), { ssr: false });
const DAY_MAP: Record<string, number> = {
sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6,
};
function parseWorkingHours(wh: WorkingHours | null) {
const fallback = {
mon: ["08:00", "18:00"] as [string, string],
tue: ["08:00", "18:00"] as [string, string],
wed: ["08:00", "18:00"] as [string, string],
thu: ["08:00", "18:00"] as [string, string],
fri: ["08:00", "18:00"] as [string, string],
sat: ["08:00", "13:00"] as [string, string],
sun: null,
};
const hours = wh && Object.keys(wh).length > 0 ? wh : fallback;
const businessHours: { daysOfWeek: number[]; startTime: string; endTime: string }[] = [];
let globalMin = "23:59";
let globalMax = "00:00";
for (const [day, range] of Object.entries(hours)) {
if (!range) continue;
const dayIndex = DAY_MAP[day];
if (dayIndex === undefined) continue;
const [start, end] = range;
businessHours.push({ daysOfWeek: [dayIndex], startTime: start, endTime: end });
if (start < globalMin) globalMin = start;
if (end > globalMax) globalMax = end;
}
return {
slotMinTime: `${globalMin}:00`,
slotMaxTime: `${globalMax}:00`,
businessHours,
};
}
const calendarPlugins = [dayGridPlugin, timeGridPlugin, interactionPlugin];
const calendarHeader = {
left: "prev,next today",
center: "title",
right: "dayGridMonth,timeGridWeek,timeGridDay",
};
const calendarButtons = {
prev: "<",
next: ">",
};
const STATUS_COLORS: Record<string, string> = {
confirmed: "#10b981",
reschedule: "#f59e0b",
cancelled: "#ef4444",
completed: "#6b7280",
};
const BLOCK_COLORS: Record<string, string> = {
surgery: "#6366f1",
admin: "#8b5cf6",
vacation: "#ec4899",
event: "#f97316",
};
/**
* Vista de calendario interactivo con FullCalendar + Supabase Realtime.
* Permite agendar citas y bloquear espacios (RF-09.1).
* Usa working_hours desde agent_config.
*/
export default function CalendarPage() {
const supabase = useMemo(() => createClient(), []);
const [events, setEvents] = useState<EventInput[]>([]);
const [workingHours, setWorkingHours] = useState<WorkingHours | null>(null);
const [initialLoad, setInitialLoad] = useState(true);
const openModal = useUIStore((s) => s.openModal);
const { slotMinTime, slotMaxTime, businessHours } = useMemo(
() => parseWorkingHours(workingHours),
[workingHours]
);
useEffect(() => {
const loadConfig = async () => {
const { data } = await supabase
.from("agent_config")
.select("working_hours")
.single();
if (data?.working_hours) {
setWorkingHours(data.working_hours as unknown as WorkingHours);
}
};
void loadConfig();
}, [supabase]);
const loadEvents = useCallback(async () => {
const now = new Date();
const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, 1);
const threeMonthsAhead = new Date(now.getFullYear(), now.getMonth() + 4, 0);
const [{ data: blocks }, { data: apptSlots }] = await Promise.all([
supabase
.from("slots")
.select("id, start_at, end_at, block_type")
.neq("block_type", "appointment")
.gte("start_at", threeMonthsAgo.toISOString())
.lte("start_at", threeMonthsAhead.toISOString())
.limit(500),
supabase
.from("slots")
.select("id, start_at, end_at, status, channel, notes, patient:patients(name), procedure:procedures(name)")
.eq("block_type", "appointment")
.in("status", ["pending", "confirmed", "completed"])
.order("created_at", { ascending: false })
.limit(200),
]);
const slotEvents: EventInput[] = (blocks ?? []).map((s) => ({
id: `slot-${s.id}`,
title: s.block_type ? `🔒 ${s.block_type}` : "Bloqueado",
start: s.start_at,
end: s.end_at,
backgroundColor: BLOCK_COLORS[s.block_type] ?? "#8b5cf6",
borderColor: "transparent",
textColor: "#fff",
extendedProps: { type: "slot", slotId: s.id },
}));
const apptEvents: EventInput[] = (apptSlots ?? []).map((a: Record<string, unknown>) => ({
id: `appt-${a.id as string}`,
title: `${(a.patient as Record<string, string>)?.name ?? "Paciente"} · ${(a.procedure as Record<string, string>)?.name ?? "Consulta"}`,
start: a.start_at as string,
end: a.end_at as string,
backgroundColor: STATUS_COLORS[a.status as string] ?? "#6b7280",
borderColor: "transparent",
textColor: "#fff",
extendedProps: { type: "appointment", appointment: a },
}));
setEvents([...slotEvents, ...apptEvents]);
setInitialLoad(false);
}, [supabase]);
useEffect(() => {
void loadEvents();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const channel = supabase
.channel("calendar-realtime")
.on("postgres_changes", { event: "*", schema: "public", table: "slots", filter: "block_type=neq.null" }, () => {
void loadEvents();
})
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [loadEvents, supabase]);
const handleDateSelect = useCallback((selectInfo: DateSelectArg) => {
openModal("slotAction", {
start: selectInfo.startStr,
end: selectInfo.endStr,
});
}, [openModal]);
const handleEventClick = useCallback((clickInfo: EventClickArg) => {
const { type, appointment } = clickInfo.event.extendedProps;
if (type === "appointment") {
openModal("appointmentDetail", { appointment });
}
}, [openModal]);
return (
<div className="space-y-6">
<div>
<h2 className="font-heading font-bold text-2xl text-foreground">Agenda Clínica</h2>
<p className="text-muted-foreground text-sm mt-1">
Gestiona citas, bloqueos y disponibilidad en tiempo real
</p>
</div>
<div className="bg-card rounded-[18px] border border-border p-4 shadow-sm overflow-hidden">
{events.length === 0 && initialLoad ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="size-6 animate-spin" />
</div>
) : (
<FullCalendar
plugins={calendarPlugins}
initialView="timeGridWeek"
locale={esLocale}
headerToolbar={calendarHeader}
buttonText={calendarButtons}
events={events}
selectable
selectMirror
select={handleDateSelect}
eventClick={handleEventClick}
height="calc(100vh - 280px)"
slotMinTime={slotMinTime}
slotMaxTime={slotMaxTime}
slotDuration="01:00:00"
slotLabelInterval="01:00"
businessHours={businessHours}
allDaySlot={false}
nowIndicator
eventDisplay="block"
dayMaxEvents={3}
/>
)}
</div>
<SlotActionModal onSuccess={loadEvents} />
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => { console.error(error); }, [error]);
return (
<div className="flex items-center justify-center h-96">
<div className="text-center space-y-4">
<h2 className="text-xl font-heading font-bold text-foreground">Error al cargar</h2>
<p className="text-sm text-muted-foreground">No se pudo cargar esta sección.</p>
<Button onClick={reset} className="rounded-[12px]">Reintentar</Button>
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
import { Sidebar } from "@/components/layout/Sidebar";
import { Topbar } from "@/components/layout/Topbar";
import { Providers } from "@/components/providers";
interface DashboardLayoutProps {
children: React.ReactNode;
params?: Promise<{ segment?: string }>;
}
/**
* Layout protegido del dashboard. Verifica sesión en el servidor
* y renderiza el shell con Sidebar + Topbar.
*/
export default async function DashboardLayout({ children }: DashboardLayoutProps) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
return (
<Providers>
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar />
{/* Main content area */}
<div className="flex flex-col flex-1 overflow-hidden">
<Topbar title="Dashboard" />
<main className="flex-1 overflow-y-auto p-8">
{children}
</main>
</div>
</div>
</Providers>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function DashboardLoading() {
return (
<div className="flex items-center justify-center h-64">
<div className="size-8 border-4 border-muted border-t-primary rounded-full animate-spin" />
</div>
);
}
+274
View File
@@ -0,0 +1,274 @@
"use client";
import { useEffect, useState, useMemo, useCallback } from "react";
import { createClient } from "@/utils/supabase/client";
import { useNotificationStore } from "@/lib/stores/notificationStore";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ConversationChat } from "@/components/chat/ConversationChat";
import { MessageSquare, CheckCircle, Clock, AlertTriangle } from "lucide-react";
import { format } from "date-fns/format";
import { es } from "date-fns/locale/es";
import { toast } from "sonner";
import type { Conversation, Patient } from "@/lib/types";
const channelConfig: Record<string, { label: string; color: string }> = {
whatsapp: { label: "WhatsApp", color: "text-emerald-600" },
messenger: { label: "Messenger", color: "text-blue-600" },
instagram: { label: "Instagram", color: "text-pink-600" },
tiktok: { label: "TikTok", color: "text-black" },
web: { label: "Web Chat", color: "text-primary" },
};
const statusConfig: Record<string, { label: string; icon: typeof MessageSquare; className: string }> = {
active: { label: "Activa", icon: MessageSquare, className: "bg-blue-50 text-blue-700 border-blue-200" },
pending_human: { label: "Pendiente", icon: AlertTriangle, className: "bg-yellow-50 text-yellow-700 border-yellow-200" },
resolved: { label: "Resuelta", icon: CheckCircle, className: "bg-emerald-50 text-emerald-700 border-emerald-200" },
};
/**
* Bandeja de todas las conversaciones con filtros por estado.
*/
export default function NotificationsPage() {
const supabase = useMemo(() => createClient(), []);
const setPendingConversations = useNotificationStore((s) => s.setPendingConversations);
const removePendingConversation = useNotificationStore((s) => s.removePendingConversation);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState("all");
const [activeChat, setActiveChat] = useState<{ conversation: Conversation; patient: Patient } | null>(null);
useEffect(() => {
const load = async () => {
try {
const { data, error } = await supabase
.from("conversations")
.select("*, patient:patients(id, name, phone)")
.order("last_message_at", { ascending: false })
.limit(100);
if (error) {
console.error("Conversations query error:", (error as Error).message);
}
if (data) {
setConversations(data as Conversation[]);
// Also update the store with pending ones
const pending = data.filter((c) => c.status === "pending_human");
setPendingConversations(pending as Conversation[]);
}
} finally {
setIsLoading(false);
}
};
void load();
const channel = supabase
.channel("notifications-page")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "conversations", filter: "status=eq.pending_human" },
() => void load()
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [supabase, setPendingConversations]);
const markResolved = useCallback(async (conversationId: string) => {
const { error } = await supabase
.from("conversations")
.update({ status: "resolved" })
.eq("id", conversationId);
if (error) {
toast.error("Error al marcar como resuelto.");
return;
}
removePendingConversation(conversationId);
toast.success("Conversación marcada como resuelta.");
}, [supabase, removePendingConversation]);
const filtered = useMemo(() => {
return activeTab === "all"
? conversations
: conversations.filter((c) => c.status === activeTab);
}, [activeTab, conversations]);
const counts = useMemo(() => {
return {
all: conversations.length,
active: conversations.filter((c) => c.status === "active").length,
pending_human: conversations.filter((c) => c.status === "pending_human").length,
resolved: conversations.filter((c) => c.status === "resolved").length,
};
}, [conversations]);
return (
<div className="space-y-6">
<div>
<h2 className="font-heading font-bold text-2xl text-foreground">
Conversaciones
</h2>
<p className="text-muted-foreground text-sm mt-1">
Todas las conversaciones del consultorio por canal
</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="bg-muted/50 rounded-[12px] w-full sm:w-auto">
<TabsTrigger value="all" className="text-xs">
Todas ({counts.all})
</TabsTrigger>
<TabsTrigger value="active" className="text-xs">
Activas ({counts.active})
</TabsTrigger>
<TabsTrigger value="pending_human" className="text-xs">
Pendientes ({counts.pending_human})
</TabsTrigger>
<TabsTrigger value="resolved" className="text-xs">
Resueltas ({counts.resolved})
</TabsTrigger>
</TabsList>
<TabsContent value={activeTab} className="mt-4">
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-[18px]" />
))}
</div>
) : filtered.length === 0 ? (
<Card className="border-border">
<CardContent className="flex flex-col items-center justify-center h-48 gap-3">
<CheckCircle className="size-10 text-muted-foreground" />
<p className="font-medium text-foreground">
{activeTab === "all"
? "Sin conversaciones"
: activeTab === "pending_human"
? "Sin conversaciones pendientes"
: `Sin conversaciones ${statusConfig[activeTab]?.label.toLowerCase() ?? ""}`}
</p>
<p className="text-muted-foreground text-sm text-center max-w-xs">
{activeTab === "pending_human"
? "Todas las conversaciones están resueltas o siendo atendidas por el agente IA."
: "No hay conversaciones en esta categoría aún."}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{filtered.map((conv) => {
const ch = channelConfig[conv.channel] ?? { label: conv.channel, color: "text-muted" };
const st = statusConfig[conv.status] ?? statusConfig.active;
const StatusIcon = st.icon;
const patient = conv.patient;
return (
<div
key={conv.id}
role="button"
tabIndex={0}
onClick={() => {
if (patient) {
setActiveChat({ conversation: conv, patient: patient as Patient });
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (patient) {
setActiveChat({ conversation: conv, patient: patient as Patient });
}
}
}}
className="w-full text-left cursor-pointer"
>
<Card
className={`rounded-[18px] transition-colors hover:bg-muted/40 cursor-pointer ${
conv.status === "pending_human"
? "border-secondary/30 bg-secondary/5"
: "border-border"
}`}
>
<CardContent className="pt-4 pb-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="size-10 rounded-full bg-secondary/20 flex items-center justify-center flex-shrink-0 mt-0.5">
<MessageSquare className="size-4 text-foreground" />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<p className="font-semibold text-sm text-foreground">
{patient?.name ?? "Paciente desconocido"}
</p>
<Badge
variant="outline"
className={`text-[10px] ${ch.color} border-current/30`}
>
{ch.label}
</Badge>
<Badge
variant="outline"
className={`text-[10px] ${st.className}`}
>
<StatusIcon className="size-3 mr-0.5" />
{st.label}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{patient?.phone ?? "—"}
</p>
<div className="flex items-center gap-1 mt-1">
<Clock className="size-3 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
{format(new Date(conv.last_message_at), "dd MMM · HH:mm", {
locale: es,
})}
</p>
</div>
</div>
</div>
{conv.status === "pending_human" && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
markResolved(conv.id);
}}
className="rounded-[12px] text-xs flex-shrink-0"
>
<CheckCircle className="size-3 mr-1" />
Resolver
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
})}
</div>
)}
</TabsContent>
</Tabs>
{activeChat && (
<ConversationChat
conversation={activeChat.conversation}
patient={activeChat.patient}
open={true}
onClose={() => setActiveChat(null)}
/>
)}
</div>
);
}
Regular → Executable
+439 -58
View File
@@ -1,66 +1,447 @@
"use client";
import { createClient } from "@/utils/supabase/server";
import Link from "next/link";
import {
CalendarDays,
Users,
BellRing,
TrendingUp,
CheckCircle2,
BarChart3,
Clock,
ArrowRight,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { format } from "date-fns/format";
import { es } from "date-fns/locale/es";
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 const dynamic = "force-dynamic";
export default function DashboardPage() {
const [aiEnabled, setAiEnabled] = useState(true);
const [date, setDate] = useState<Date | undefined>(new Date());
const STATUS_COLORS: Record<string, string> = {
confirmed: "bg-emerald-50 text-emerald-700 border-emerald-200",
cancelled: "bg-red-50 text-red-700 border-red-200",
completed: "bg-gray-50 text-gray-600 border-gray-200",
reschedule: "bg-yellow-50 text-yellow-700 border-yellow-200",
};
const STATUS_LABELS: Record<string, string> = {
confirmed: "Confirmada",
cancelled: "Cancelada",
completed: "Completada",
reschedule: "Reprogramar",
};
const CHANNEL_LABELS: Record<string, string> = {
whatsapp: "WhatsApp",
messenger: "Messenger",
instagram: "Instagram",
tiktok: "TikTok",
web: "Web",
};
async function getDashboardData() {
const supabase = await createClient();
const now = new Date();
const offset = -5 * 60;
const local = new Date(now.getTime() + now.getTimezoneOffset() * 60000 + offset * 60000);
const today = local.toISOString().split("T")[0];
const [
{ data: todayApps },
{ count: totalPatients },
{ count: pendingConversations },
{ data: monthlyRaw },
{ data: topProcRaw },
{ data: channelRaw },
{ count: totalCompleted },
] = await Promise.all([
supabase
.from("slots")
.select("*, patient:patients(name, phone), procedure:procedures(name)")
.eq("block_type", "appointment")
.gte("start_at", `${today}T00:00:00Z`)
.lt("start_at", `${today}T23:59:59Z`)
.order("start_at", { ascending: true }),
supabase.from("patients").select("*", { count: "exact", head: true }),
supabase
.from("conversations")
.select("*", { count: "exact", head: true })
.eq("status", "pending_human"),
supabase
.from("slots")
.select("created_at")
.eq("block_type", "appointment")
.gte("created_at", new Date(Date.now() - 180 * 86400000).toISOString())
.limit(500),
supabase
.from("slots")
.select("procedure_id, procedure:procedures(name)")
.eq("block_type", "appointment")
.not("procedure_id", "is", null)
.limit(500),
supabase
.from("slots")
.select("channel")
.eq("block_type", "appointment")
.limit(500),
supabase
.from("slots")
.select("*", { count: "exact", head: true })
.eq("block_type", "appointment")
.eq("status", "completed"),
]);
// Compute monthly stats
const byMonth: Record<string, number> = {};
for (const a of (monthlyRaw ?? [])) {
const m = (a as { created_at: string }).created_at.slice(0, 7);
byMonth[m] = (byMonth[m] || 0) + 1;
}
const monthlyStats: MonthlyStat[] = Object.entries(byMonth)
.sort(([a], [b]) => a.localeCompare(b))
.slice(-6)
.map(([month, total]) => ({ month, total }));
// Compute top procedures
const byProc: Record<string, number> = {};
for (const a of (topProcRaw ?? [])) {
const procedures = (a as { procedure: { name: string }[] }).procedure;
const name = (Array.isArray(procedures) ? procedures[0]?.name : null) ?? "Sin especificar";
byProc[name] = (byProc[name] || 0) + 1;
}
const topProcedures: ProcedureStat[] = Object.entries(byProc)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([name, count]) => ({ name, count }));
// Compute channel stats
const byChannel: Record<string, number> = {};
for (const a of (channelRaw ?? [])) {
const ch = (a as { channel: string }).channel || "web";
byChannel[ch] = (byChannel[ch] || 0) + 1;
}
const channelStats: ChannelStat[] = Object.entries(byChannel)
.sort(([, a], [, b]) => b - a)
.map(([channel, count]) => ({ channel, count }));
return {
todayApps: (todayApps ?? []) as TodayAppointment[],
totalPatients: totalPatients ?? 0,
pendingConversations: pendingConversations ?? 0,
totalCompleted: totalCompleted ?? 0,
monthlyStats,
topProcedures,
channelStats,
};
}
type TodayAppointment = {
id: string;
status: string;
channel: string;
start_at: string;
end_at: string;
patient: { name: string; phone: string } | null;
procedure: { name: string } | null;
};
type MonthlyStat = { month: string; total: number };
type ProcedureStat = { name: string; count: number };
type ChannelStat = { channel: string; count: number };
function monthLabel(month: string) {
const [y, m] = month.split("-");
const months = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"];
return `${months[parseInt(m) - 1]} ${y.slice(2)}`;
}
/**
* Dashboard portada: citas del día (izq) + estadísticas del negocio (der).
*/
export default async function DashboardPage() {
const d = await getDashboardData();
const completionPct =
d.totalCompleted > 0 && d.totalPatients > 0
? Math.round((d.totalCompleted / d.totalPatients) * 100)
: 0;
const maxMonthly = Math.max(1, ...d.monthlyStats.map((m: MonthlyStat) => m.total));
const maxProc = Math.max(1, ...d.topProcedures.map((p: ProcedureStat) => p.count));
const maxChan = Math.max(1, ...d.channelStats.map((c: ChannelStat) => c.count));
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 className="space-y-6">
{/* Título */}
<div>
<h2 className="font-heading font-bold text-2xl text-foreground">
Vista General
</h2>
<p className="text-muted-foreground text-sm mt-1">
{format(new Date(), "EEEE d 'de' MMMM, yyyy", { locale: es })}
</p>
</div>
{/* ─── MAIN GRID: Citas del día (izq) + Estadísticas (der) ─── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* ──────── LEFT: Citas de hoy ──────── */}
<div className="lg:col-span-2">
<Card className="border-border h-full">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CalendarDays className="size-4 text-primary" />
<CardTitle className="font-heading text-base font-semibold">
Citas de Hoy
</CardTitle>
</div>
<Link
href="/dashboard/calendar"
className="text-xs text-primary hover:underline font-medium flex items-center gap-1"
>
Ver todas <ArrowRight className="size-3" />
</Link>
</div>
</CardHeader>
<CardContent>
{d.todayApps.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<CalendarDays className="size-10 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No hay citas programadas para hoy</p>
</div>
) : (
<div className="space-y-2">
{d.todayApps.map((apt) => (
<div
key={apt.id}
className="flex items-center gap-4 p-3 rounded-[12px] bg-muted/20 border border-border/50 hover:bg-muted/40 transition-colors"
>
{/* Hora */}
<div className="w-16 shrink-0 text-center">
<p className="text-sm font-heading font-bold text-foreground">
{apt.start_at
? format(new Date(apt.start_at), "HH:mm")
: "—:—"}
</p>
<p className="text-[10px] text-muted-foreground">
{apt.start_at
? format(new Date(apt.start_at), "haaa").replace(". m.", "m")
: ""}
</p>
</div>
{/* Divider */}
<div className="w-px h-8 bg-border/50 shrink-0" />
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{apt.patient?.name ?? "Paciente"}
</p>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-xs text-muted-foreground truncate">
{apt.procedure?.name ?? "Procedimiento"}
</p>
<span className="text-[10px] text-muted-foreground/60">
{CHANNEL_LABELS[apt.channel] ?? apt.channel}
</span>
</div>
</div>
{/* Status */}
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${STATUS_COLORS[apt.status] ?? STATUS_COLORS.confirmed}`}
>
{STATUS_LABELS[apt.status] ?? apt.status}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* ──────── RIGHT: Estadísticas de negocio ──────── */}
<div className="space-y-4">
{/* Quick KPIs */}
<div className="grid grid-cols-2 gap-3">
<Card className="border-border">
<CardContent className="pt-4 pb-3">
<Users className="size-4 text-primary mb-1.5" />
<p className="text-2xl font-heading font-bold text-foreground">
{d.totalPatients}
</p>
<p className="text-[10px] text-muted-foreground">Pacientes</p>
</CardContent>
</Card>
<Card className="border-border">
<CardContent className="pt-4 pb-3">
<CheckCircle2 className="size-4 text-emerald-500 mb-1.5" />
<p className="text-2xl font-heading font-bold text-foreground">
{completionPct}%
</p>
<p className="text-[10px] text-muted-foreground">Completadas</p>
</CardContent>
</Card>
<Card className="border-border">
<CardContent className="pt-4 pb-3">
<BellRing className="size-4 text-secondary mb-1.5" />
<p className="text-2xl font-heading font-bold text-foreground">
{d.pendingConversations}
</p>
<p className="text-[10px] text-muted-foreground">Pendientes</p>
</CardContent>
</Card>
<Card className="border-border">
<CardContent className="pt-4 pb-3">
<TrendingUp className="size-4 text-accent mb-1.5" />
<p className="text-2xl font-heading font-bold text-foreground">
{d.todayApps.length}
</p>
<p className="text-[10px] text-muted-foreground">Hoy</p>
</CardContent>
</Card>
</div>
{/* Monthly chart */}
<Card className="border-border">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<BarChart3 className="size-3.5 text-muted-foreground" />
<CardTitle className="font-heading text-sm font-semibold">
Citas por mes
</CardTitle>
</div>
</CardHeader>
<CardContent>
{d.monthlyStats.length === 0 ? (
<p className="text-xs text-muted-foreground py-4 text-center">Sin datos</p>
) : (
<div className="space-y-2.5">
{d.monthlyStats.map((m: MonthlyStat) => (
<div key={m.month} className="space-y-1">
<div className="flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{monthLabel(m.month)}</span>
<span className="font-medium text-foreground">{m.total}</span>
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.round((m.total / maxMonthly) * 100)}%` }}
/>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Top procedures */}
<Card className="border-border">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<TrendingUp className="size-3.5 text-muted-foreground" />
<CardTitle className="font-heading text-sm font-semibold">
Top procedimientos
</CardTitle>
</div>
</CardHeader>
<CardContent>
{d.topProcedures.length === 0 ? (
<p className="text-xs text-muted-foreground py-4 text-center">Sin datos</p>
) : (
<div className="space-y-2">
{d.topProcedures.map((p: ProcedureStat, i: number) => (
<div key={p.name} className="flex items-center gap-2">
<span className="text-[10px] font-bold text-muted-foreground w-4 text-right">
{i + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between text-[10px]">
<span className="text-foreground font-medium truncate">{p.name}</span>
<span className="text-muted-foreground ml-2">{p.count}</span>
</div>
<div className="h-1 rounded-full bg-muted mt-1 overflow-hidden">
<div
className="h-full rounded-full bg-accent transition-all"
style={{ width: `${Math.round((p.count / maxProc) * 100)}%` }}
/>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Channel distribution */}
<Card className="border-border">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Clock className="size-3.5 text-muted-foreground" />
<CardTitle className="font-heading text-sm font-semibold">
Canales
</CardTitle>
</div>
</CardHeader>
<CardContent>
{d.channelStats.length === 0 ? (
<p className="text-xs text-muted-foreground py-4 text-center">Sin datos</p>
) : (
<div className="space-y-1.5">
{d.channelStats.map((c: ChannelStat) => (
<div key={c.channel} className="flex items-center justify-between text-xs">
<span className="text-foreground capitalize">
{CHANNEL_LABELS[c.channel] ?? c.channel}
</span>
<span className="text-muted-foreground font-medium">{c.count}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* ─── Bottom: Pending alert ─── */}
{d.pendingConversations > 0 && (
<Card className="border-secondary/30 bg-secondary/5">
<CardContent className="pt-5 pb-5">
<div className="flex items-center gap-3">
<div className="size-10 rounded-[12px] bg-secondary/20 flex items-center justify-center shrink-0">
<BellRing className="size-5 text-secondary-foreground" />
</div>
<div className="flex-1">
<p className="font-medium text-sm text-foreground">
{d.pendingConversations} conversación
{d.pendingConversations !== 1 ? "es" : ""} pendiente
{d.pendingConversations !== 1 ? "s" : ""}
</p>
<p className="text-xs text-muted-foreground">
Pacientes esperando respuesta humana
</p>
</div>
<Link
href="/dashboard/notifications"
className="text-sm text-primary font-medium hover:underline flex items-center gap-1 shrink-0"
>
Atender <ArrowRight className="size-3" />
</Link>
</div>
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,7 @@
export default function PatientLoading() {
return (
<div className="flex items-center justify-center h-64">
<div className="size-8 border-4 border-muted border-t-primary rounded-full animate-spin" />
</div>
);
}
+201
View File
@@ -0,0 +1,201 @@
import { createClient } from "@/utils/supabase/server";
import { notFound } from "next/navigation";
import Link from "next/link";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft } from "lucide-react";
import { format } from "date-fns/format";
import { es } from "date-fns/locale/es";
import { PatientNotes } from "@/components/patients/PatientNotes";
import { PatientEditor } from "@/components/patients/PatientEditor";
import { PatientAppointmentsList } from "@/components/patients/PatientAppointmentsList";
import type { Message } from "@/lib/types";
export const dynamic = "force-dynamic";
interface PatientDetailPageProps {
params: Promise<{ id: string }>;
}
async function getPatient(id: string) {
const supabase = await createClient();
const [{ data: patient, error: patientError }, { data: messages, error: messagesError }] =
await Promise.all([
supabase
.from("patients")
.select("*, patient_profile:patient_profiles(*)")
.eq("id", id)
.single(),
supabase
.from("messages")
.select("id, patient_id, conversation_id, channel, direction, type, content, transcript, created_at")
.eq("patient_id", id)
.order("created_at", { ascending: false })
.limit(50),
]);
if (patientError) console.error("getPatient: patient error:", patientError.message);
if (messagesError) console.error("getPatient: messages error:", messagesError.message);
// patient_profiles join returns an array - extract first element
const profile = Array.isArray(patient?.patient_profile)
? patient.patient_profile[0]
: patient?.patient_profile ?? null;
const normalizedPatient = patient
? { ...patient, patient_profile: profile }
: null;
return {
patient: normalizedPatient,
messages: (messages ?? []) as Message[],
};
}
const channelEmoji: Record<string, string> = {
whatsapp: "💬",
messenger: "💙",
instagram: "📸",
tiktok: "🎵",
web: "🌐",
manual: "✍️",
};
/**
* Ficha completa del paciente con Tabs: datos, citas, conversaciones y notas.
*/
export default async function PatientDetailPage({ params }: PatientDetailPageProps) {
const { id } = await params;
// Validate UUID before querying
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(id)) {
notFound();
}
const { patient, messages } = await getPatient(id);
if (!patient) notFound();
return (
<div className="space-y-6 max-w-4xl">
{/* Botón volver */}
<Link
href="/dashboard/patients"
className="inline-flex items-center gap-1.5 rounded-[12px] text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 px-3 py-2 -ml-3 transition-colors"
>
<ArrowLeft className="size-4" />
Volver a pacientes
</Link>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="font-heading font-bold text-2xl text-foreground">
{patient.name}
</h2>
<p className="text-muted-foreground text-sm mt-1">
{patient.phone}
{patient.email ? ` · ${patient.email}` : ""}
</p>
</div>
<Badge
variant="outline"
className={`capitalize ${
patient.status === "activo"
? "text-emerald-700 bg-emerald-50 border-emerald-200"
: patient.status === "prospecto"
? "text-yellow-700 bg-yellow-50 border-yellow-200"
: "text-gray-600 bg-gray-50 border-gray-200"
}`}
>
{patient.status}
</Badge>
</div>
<Tabs defaultValue="datos">
<TabsList className="bg-muted/50 rounded-[12px]">
<TabsTrigger value="datos">Datos Personales</TabsTrigger>
<TabsTrigger value="citas">
Historial de Citas
</TabsTrigger>
<TabsTrigger value="conversaciones">
Conversaciones ({messages.length})
</TabsTrigger>
<TabsTrigger value="notas">Notas Clínicas</TabsTrigger>
</TabsList>
{/* Datos Personales */}
<TabsContent value="datos" className="mt-4">
<PatientEditor patient={patient} />
</TabsContent>
{/* Historial de citas */}
<TabsContent value="citas" className="mt-4">
<PatientAppointmentsList patientId={patient.id} />
</TabsContent>
{/* Conversaciones */}
<TabsContent value="conversaciones" className="mt-4">
<Card className="border-border">
<CardContent className="pt-6">
{messages.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-8">
No hay mensajes registrados.
</p>
) : (
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-2">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex gap-2 ${
msg.direction === "out" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[75%] rounded-[12px] px-3 py-2 text-sm ${
msg.direction === "out"
? "bg-primary/10 text-foreground"
: "bg-muted text-foreground"
}`}
>
<p className="leading-relaxed">{msg.transcript ?? msg.content}</p>
<p className="text-[10px] text-muted-foreground mt-1">
{channelEmoji[msg.channel] ?? "💬"}{" "}
{format(new Date(msg.created_at), "HH:mm", { locale: es })}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Notas Clínicas */}
<TabsContent value="notas" className="mt-4">
<Card className="border-border">
<CardContent className="pt-6">
{patient.patient_profile?.id ? (
<PatientNotes
profileId={patient.patient_profile.id}
initialNotes={patient.patient_profile.notes ?? null}
/>
) : (
<p className="text-sm text-muted-foreground">
No hay perfil clínico registrado para este paciente.
</p>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { Search, UserCheck, UserX, Clock } from "lucide-react";
import { createClient } from "@/utils/supabase/client";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { Patient } from "@/lib/types";
const statusConfig = {
prospecto: { label: "Prospecto", icon: Clock, className: "bg-yellow-50 text-yellow-700 border-yellow-200" },
activo: { label: "Activo", icon: UserCheck, className: "bg-emerald-50 text-emerald-700 border-emerald-200" },
inactivo: { label: "Inactivo", icon: UserX, className: "bg-gray-50 text-gray-600 border-gray-200" },
};
/**
* Listado de pacientes con búsqueda full-text y navegación a ficha (RF-09.2).
*/
export default function PatientsPage() {
const router = useRouter();
const supabase = useMemo(() => createClient(), []);
const [patients, setPatients] = useState<Patient[]>([]);
const [query, setQuery] = useState("");
const [isLoading, setIsLoading] = useState(true);
const abortRef = useRef<AbortController | null>(null);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchPatients = useCallback(
async (search: string) => {
if (abortRef.current) {
abortRef.current.abort();
}
const controller = new AbortController();
abortRef.current = controller;
setIsLoading(true);
const trimmed = search.trim();
let q = supabase
.from("patients")
.select("id, phone, name, email, status, created_at")
.order("created_at", { ascending: false })
.limit(100);
if (trimmed) {
const escaped = trimmed.replace(/[%_]/g, "\\$&");
q = q.or(
`name.ilike.%${escaped}%,phone.ilike.%${escaped}%,email.ilike.%${escaped}%`
);
}
const { data } = await q;
if (!controller.signal.aborted) {
setPatients((data ?? []) as Patient[]);
setIsLoading(false);
}
},
[supabase]
);
// Initial load
useEffect(() => {
void fetchPatients("");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSearch = useCallback((value: string) => {
setQuery(value);
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
searchTimeoutRef.current = setTimeout(() => {
void fetchPatients(value);
}, 300);
}, [fetchPatients]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h2 className="font-heading font-bold text-2xl text-foreground">Pacientes</h2>
<p className="text-muted-foreground text-sm mt-1">
Gestiona los perfiles y el historial clínico
</p>
</div>
</div>
{/* Búsqueda */}
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="patient-search"
type="search"
placeholder="Buscar por nombre, teléfono o email…"
value={query}
onChange={(e) => handleSearch(e.target.value)}
className="pl-9"
/>
</div>
{/* Lista */}
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-28 rounded-[18px]" />
))}
</div>
) : patients.length === 0 ? (
<Card className="border-border">
<CardContent className="flex items-center justify-center h-40">
<p className="text-muted-foreground text-sm">
{query ? "No se encontraron pacientes con ese criterio." : "No hay pacientes registrados aún."}
</p>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{patients.map((patient) => {
const cfg = statusConfig[patient.status] ?? statusConfig.prospecto;
const Icon = cfg.icon;
return (
<button
key={patient.id}
onClick={() => router.push(`/dashboard/patients/${patient.id}`)}
className="text-left"
>
<Card className="border-border hover:border-primary/40 hover:shadow-md transition-all cursor-pointer rounded-[18px]">
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="font-heading text-base font-semibold truncate">
{patient.name}
</CardTitle>
<Badge
variant="outline"
className={`text-[10px] px-2 py-0.5 flex items-center gap-1 ${cfg.className}`}
>
<Icon className="size-3" />
{cfg.label}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-1">
<p className="text-xs text-muted-foreground">{patient.phone}</p>
{patient.email && (
<p className="text-xs text-muted-foreground truncate">{patient.email}</p>
)}
</CardContent>
</Card>
</button>
);
})}
</div>
)}
{!isLoading && patients.length > 0 && (
<p className="text-xs text-muted-foreground">
{patients.length} paciente{patients.length !== 1 ? "s" : ""} mostrados
</p>
)}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function ProceduresLoading() {
return (
<div className="flex items-center justify-center h-64">
<div className="size-8 border-4 border-muted border-t-primary rounded-full animate-spin" />
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Plus, Clock } from "lucide-react";
import type { Procedure } from "@/lib/types";
async function getProcedures() {
const supabase = await createClient();
const { data } = await supabase
.from("procedures")
.select("*")
.order("name", { ascending: true });
return (data ?? []) as Procedure[];
}
/**
* Gestión de procedimientos e indicaciones (RF-09.3).
*/
export default async function ProceduresPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user || user.app_metadata?.role !== "admin") redirect("/dashboard");
const procedures = await getProcedures();
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h2 className="font-heading font-bold text-2xl text-foreground">
Procedimientos
</h2>
<p className="text-muted-foreground text-sm mt-1">
Catálogo de procedimientos e indicaciones de preparación
</p>
</div>
<Button className="bg-primary text-[#0D141D] font-semibold rounded-[18px] hover:bg-primary/90">
<Plus className="size-4 mr-2" />
Nuevo procedimiento
</Button>
</div>
{procedures.length === 0 ? (
<Card className="border-border">
<CardContent className="flex items-center justify-center h-40 text-muted-foreground text-sm">
No hay procedimientos configurados.
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{procedures.map((proc) => (
<Card key={proc.id} className="border-border rounded-[18px]">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<CardTitle className="font-heading text-base font-semibold">
{proc.name}
</CardTitle>
<Badge
variant="outline"
className={
proc.is_active
? "text-emerald-700 bg-emerald-50 border-emerald-200 text-[10px]"
: "text-gray-600 bg-gray-50 border-gray-200 text-[10px]"
}
>
{proc.is_active ? "Activo" : "Inactivo"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
{proc.description && (
<p className="text-sm text-muted-foreground">{proc.description}</p>
)}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="size-3" />
<span>{proc.duration_min} minutos</span>
</div>
{proc.preparation_notes && (
<div className="bg-muted/30 rounded-[12px] p-3">
<p className="text-xs font-medium text-foreground mb-1">
Indicaciones de preparación:
</p>
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
{proc.preparation_notes}
</p>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function SettingsLoading() {
return (
<div className="flex items-center justify-center h-64">
<div className="size-8 border-4 border-muted border-t-primary rounded-full animate-spin" />
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Save } from "lucide-react";
import type { AgentConfig } from "@/lib/types";
async function getConfig() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user || user.app_metadata?.role !== "admin") {
redirect("/dashboard");
}
const { data, error } = await supabase
.from("agent_config")
.select("*")
.single();
if (error && error.code !== "PGRST116") {
redirect("/dashboard");
}
return data as AgentConfig | null;
}
const DAY_LABELS: Record<string, string> = {
mon: "Lunes",
tue: "Martes",
wed: "Miércoles",
thu: "Jueves",
fri: "Viernes",
sat: "Sábado",
sun: "Domingo",
};
const DAY_ORDER = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
/**
* Página de configuración del agente IA y del consultorio (RF-09.5).
* Solo accesible para rol admin.
*/
export default async function SettingsPage() {
const config = await getConfig();
const workingHours = config?.working_hours ?? {};
return (
<div className="space-y-6 max-w-3xl">
<div>
<h2 className="font-heading font-bold text-2xl text-foreground">Configuración</h2>
<p className="text-muted-foreground text-sm mt-1">
Configura el agente IA, horarios y parámetros del consultorio
</p>
</div>
{/* System Prompt */}
<Card className="border-border rounded-[18px]">
<CardHeader>
<CardTitle className="font-heading text-base font-semibold">
Prompt del Agente IA
</CardTitle>
<CardDescription className="text-xs">
Define la personalidad y restricciones del asistente virtual. Este texto
se envía como contexto en cada conversación.
</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4">
<div className="space-y-2">
<Label htmlFor="system-prompt" className="text-sm font-medium">
Instrucciones del sistema
</Label>
<Textarea
id="system-prompt"
defaultValue={config?.system_prompt ?? ""}
rows={10}
placeholder="Eres un asistente de atención al paciente para la Clínica Dr. Ejemplo…"
className="text-sm resize-none"
readOnly
/>
</div>
<Button
className="bg-primary text-[#0D141D] font-semibold rounded-[18px] hover:bg-primary/90"
type="button"
disabled
>
<Save className="size-4 mr-2" />
Guardar cambios
</Button>
</form>
</CardContent>
</Card>
{/* Working Hours */}
<Card className="border-border rounded-[18px]">
<CardHeader>
<CardTitle className="font-heading text-base font-semibold">
Horario de Atención
</CardTitle>
<CardDescription className="text-xs">
El agente solo ofrecerá slots dentro de este horario.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{DAY_ORDER.map((day) => {
const hours = workingHours[day];
return (
<div key={day} className="flex items-center justify-between py-1 border-b border-border/50 last:border-0">
<span className="text-sm font-medium text-foreground capitalize">
{DAY_LABELS[day] ?? day}
</span>
<span className="text-sm text-muted-foreground">
{hours ? `${hours[0]} ${hours[1]}` : "Cerrado"}
</span>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Canales habilitados */}
<Card className="border-border rounded-[18px]">
<CardHeader>
<CardTitle className="font-heading text-base font-semibold">
Canales Habilitados
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{(config?.enabled_channels ?? ["whatsapp", "web"]).map((ch) => (
<span
key={ch}
className="px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium capitalize"
>
{ch}
</span>
))}
</div>
</CardContent>
</Card>
</div>
);
}