Añadido Dockerfil docker-compose.yml
This commit is contained in:
Executable
+147
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+236
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+39
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+274
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+201
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+169
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+97
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+148
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user