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
+710
View File
@@ -0,0 +1,710 @@
"use client";
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import {
Loader2,
Lock,
CalendarPlus,
Clock,
CalendarDays,
Trash2,
} from "lucide-react";
import { format } from "date-fns/format";
import { startOfDay } from "date-fns/startOfDay";
import { setHours } from "date-fns/setHours";
import { setMinutes } from "date-fns/setMinutes";
import { es } from "date-fns/locale/es";
import { DayPicker } from "react-day-picker";
import { createClient } from "@/utils/supabase/client";
import { useUIStore } from "@/lib/stores/uiStore";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { Patient, Procedure } from "@/lib/types";
// ─── Schemas ────────────────────────────────────────────────────────────────
const blockSchema = z.object({
dates: z.array(z.date()).min(1, "Selecciona al menos un día"),
startTime: z.string().min(1, "Hora de inicio requerida"),
endTime: z.string().min(1, "Hora de fin requerida"),
blockType: z.enum(["surgery", "admin", "vacation", "event"]),
notes: z.string().optional(),
}).refine((d) => {
if (!d.startTime || !d.endTime) return true;
return d.startTime < d.endTime;
}, {
message: "La hora de fin debe ser posterior a la de inicio",
path: ["endTime"],
});
const appointmentSchema = z.object({
patientSearch: z.string(),
patientId: z.string().min(1, "Selecciona un paciente"),
procedureId: z.string().min(1, "Selecciona un procedimiento"),
startTime: z.string().min(1, "Hora de inicio requerida"),
endTime: z.string().min(1, "Hora de fin requerida"),
channel: z.enum(["whatsapp", "messenger", "instagram", "tiktok", "web", "manual"]),
}).refine((d) => d.patientId.length > 0 && d.patientId !== "", {
message: "Selecciona un paciente válido",
path: ["patientId"],
}).refine((d) => {
if (!d.startTime || !d.endTime) return true;
return d.startTime < d.endTime;
}, {
message: "La hora de fin debe ser posterior a la de inicio",
path: ["endTime"],
});
type BlockValues = z.infer<typeof blockSchema>;
type AppointmentValues = z.infer<typeof appointmentSchema>;
// ─── Props ───────────────────────────────────────────────────────────────────
interface SlotActionModalProps {
onSuccess?: () => void;
}
/**
* Modal de acción sobre un slot del calendario.
* Permite: bloquear espacio o agendar cita manual (RF-09.1.2 / RF-09.1.5).
*/
export function SlotActionModal({ onSuccess }: SlotActionModalProps) {
const supabase = useMemo(() => createClient(), []);
const activeModal = useUIStore((s) => s.activeModal);
const modalPayload = useUIStore((s) => s.modalPayload);
const closeModal = useUIStore((s) => s.closeModal);
const isOpen = activeModal === "slotAction";
const [patients, setPatients] = useState<Patient[]>([]);
const [procedures, setProcedures] = useState<Procedure[]>([]);
const [searchValue, setSearchValue] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [calendarMonth, setCalendarMonth] = useState<Date>(new Date());
const [showCalendar, setShowCalendar] = useState(false);
// Extract initial date range from FullCalendar selection
const initialDates = useMemo(() => {
if (!modalPayload) return [];
const { start, end } = modalPayload as { start: string; end: string };
if (!start || !end) return [];
const startDate = startOfDay(new Date(start));
const endDate = startOfDay(new Date(end));
// end is exclusive in FullCalendar; subtract 1 day for multi-day selections
const adjustedEnd = new Date(endDate.getTime() - 86400000);
if (adjustedEnd.getTime() <= startDate.getTime()) {
return [startDate];
}
const dates: Date[] = [];
let current = startDate;
while (current <= adjustedEnd) {
dates.push(new Date(current));
current = new Date(current.getTime() + 86400000);
}
return dates;
}, [modalPayload]);
const initialStartTime = useMemo(() => {
if (!modalPayload) return "08:00";
const { start } = modalPayload as { start: string; end: string };
if (!start) return "08:00";
const time = format(new Date(start), "HH:mm");
return time === "00:00" ? "08:00" : time;
}, [modalPayload]);
const initialEndTime = useMemo(() => {
if (!modalPayload) return "09:00";
const { end, start } = modalPayload as { start: string; end: string };
if (!end) return "09:00";
const endTime = format(new Date(end), "HH:mm");
if (endTime === "00:00") {
const st = start ? format(new Date(start), "HH:mm") : "08:00";
if (st === "00:00") return "09:00";
const [h, m] = st.split(":").map(Number);
const totalMinutes = h * 60 + m + 60;
const newH = Math.floor(totalMinutes / 60) % 24;
const newM = totalMinutes % 60;
return `${String(newH).padStart(2, "0")}:${String(newM).padStart(2, "0")}`;
}
return endTime;
}, [modalPayload]);
// Block form
const blockForm = useForm<BlockValues>({
resolver: zodResolver(blockSchema),
defaultValues: {
dates: initialDates,
startTime: initialStartTime,
endTime: initialEndTime,
blockType: "admin",
notes: "",
},
});
const selectedDates = blockForm.watch("dates");
const selectedStartTime = blockForm.watch("startTime");
const selectedEndTime = blockForm.watch("endTime");
// Load procedures on mount + reset form on open
useEffect(() => {
if (isOpen) {
const load = async () => {
const { data } = await supabase
.from("procedures")
.select("id, name, duration_min")
.eq("is_active", true)
.order("name");
setProcedures((data ?? []) as Procedure[]);
};
void load();
// Reset form state
apptForm.reset({ patientSearch: "", patientId: "", procedureId: "", startTime: initialStartTime, endTime: initialEndTime, channel: "web" });
blockForm.reset({
dates: initialDates,
startTime: initialStartTime,
endTime: initialEndTime,
blockType: "admin",
notes: "",
});
setSearchValue("");
setPatients([]);
setShowCalendar(false);
if (initialDates.length > 0) {
setCalendarMonth(initialDates[0]);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Search patients with debounce
const searchPatients = useCallback(
async (query: string) => {
const trimmed = query.trim();
if (!trimmed) {
setPatients([]);
return;
}
setIsSearching(true);
const escaped = trimmed.replace(/[%_]/g, "\\$&");
const { data } = await supabase
.from("patients")
.select("id, name, phone")
.or(`name.ilike.%${escaped}%,phone.ilike.%${escaped}%`)
.limit(5);
setPatients((data ?? []) as Patient[]);
setIsSearching(false);
},
[supabase]
);
const removeDate = (dateToRemove: Date) => {
const current = blockForm.getValues("dates");
blockForm.setValue(
"dates",
current.filter((d) => d.toDateString() !== dateToRemove.toDateString()),
{ shouldValidate: true, shouldDirty: true }
);
};
const onBlock = useCallback(async (values: BlockValues) => {
const [startH, startM] = values.startTime.split(":").map(Number);
const [endH, endM] = values.endTime.split(":").map(Number);
const slots = values.dates.map((date) => {
const startAt = setMinutes(setHours(startOfDay(date), startH), startM);
const endAt = setMinutes(setHours(startOfDay(date), endH), endM);
return {
start_at: startAt.toISOString(),
end_at: endAt.toISOString(),
block_type: values.blockType,
status: "blocked",
};
});
const { error } = await supabase.from("slots").insert(slots);
if (error) {
toast.error("Error al bloquear los espacios.");
return;
}
toast.success(
`${slots.length} espacio(s) bloqueado(s): ${values.blockType}`
);
closeModal();
onSuccess?.();
}, [closeModal, onSuccess, supabase]);
// Appointment form
const apptForm = useForm<AppointmentValues>({
resolver: zodResolver(appointmentSchema),
defaultValues: { patientSearch: "", patientId: "", procedureId: "", startTime: initialStartTime, endTime: initialEndTime, channel: "web" },
mode: "onChange",
});
const onAppointment = useCallback(async (values: AppointmentValues) => {
const { start } = modalPayload as { start: string; end: string };
const dateStr = format(new Date(start), "yyyy-MM-dd");
const [startH, startM] = values.startTime.split(":").map(Number);
const [endH, endM] = values.endTime.split(":").map(Number);
const startAt = new Date(`${dateStr}T${String(startH).padStart(2, "0")}:${String(startM).padStart(2, "0")}:00`);
const endAt = new Date(`${dateStr}T${String(endH).padStart(2, "0")}:${String(endM).padStart(2, "0")}:00`);
const { error } = await supabase.from("slots").insert({
start_at: startAt.toISOString(),
end_at: endAt.toISOString(),
block_type: "appointment",
patient_id: values.patientId,
procedure_id: values.procedureId,
status: "confirmed",
channel: values.channel,
});
if (error) {
toast.error("Error al crear la cita.");
return;
}
toast.success("Cita agendada correctamente.");
closeModal();
onSuccess?.();
}, [closeModal, modalPayload, onSuccess, supabase]);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
<DialogContent className="max-w-lg rounded-[18px]">
<DialogHeader>
<DialogTitle className="font-heading text-lg font-semibold">
Acción sobre el espacio
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="appointment" className="mt-2">
<TabsList className="bg-muted/50 rounded-[12px] w-full">
<TabsTrigger value="appointment" className="flex-1 text-xs">
<CalendarPlus className="size-3 mr-1.5" />
Agendar cita
</TabsTrigger>
<TabsTrigger value="block" className="flex-1 text-xs">
<Lock className="size-3 mr-1.5" />
Bloquear espacio
</TabsTrigger>
</TabsList>
{/* Agendar cita */}
<TabsContent value="appointment" className="mt-4">
<form
onSubmit={apptForm.handleSubmit(onAppointment)}
className="space-y-4"
>
{/* Buscar paciente */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">Paciente</Label>
<Input
placeholder="Buscar por nombre o teléfono…"
value={searchValue}
onChange={(e) => {
const value = e.target.value;
setSearchValue(value);
apptForm.setValue("patientSearch", value);
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
searchTimeoutRef.current = setTimeout(() => {
void searchPatients(value);
}, 300);
}}
/>
{isSearching && (
<p className="text-xs text-muted-foreground">Buscando</p>
)}
{patients.length > 0 && (
<div className="border border-border rounded-[12px] overflow-hidden">
{patients.map((p) => (
<button
key={p.id}
type="button"
className="w-full text-left px-3 py-2 text-sm hover:bg-muted/50 transition-colors border-b last:border-0 border-border/50"
onClick={() => {
apptForm.setValue("patientId", p.id, { shouldValidate: true, shouldDirty: true });
apptForm.setValue("patientSearch", p.name, { shouldValidate: true });
setSearchValue(p.name);
setPatients([]);
}}
>
<p className="font-medium">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.phone}</p>
</button>
))}
</div>
)}
{apptForm.formState.errors.patientId && (
<p className="text-xs text-destructive">
{apptForm.formState.errors.patientId.message}
</p>
)}
</div>
{/* patientId hidden - registered for react-hook-form tracking */}
<Controller
control={apptForm.control}
name="patientId"
render={({ field }) => <input type="hidden" {...field} />}
/>
{/* Procedimiento */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">Procedimiento</Label>
<Controller
control={apptForm.control}
name="procedureId"
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v: string | null) => field.onChange(v ?? "")}
>
<SelectTrigger className="rounded-[12px]">
<SelectValue placeholder="Seleccionar procedimiento" />
</SelectTrigger>
<SelectContent>
{procedures.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} ({p.duration_min} min)
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
{/* Horario */}
<div className="space-y-1.5">
<Label className="text-sm font-medium flex items-center gap-1.5">
<Clock className="size-3.5 text-muted-foreground" />
Horario de la cita
</Label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Hora inicio</Label>
<Input
type="time"
value={apptForm.watch("startTime") ?? initialStartTime}
onChange={(e) =>
apptForm.setValue("startTime", e.target.value, {
shouldValidate: true,
shouldDirty: true,
})
}
className="rounded-[12px] h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Hora fin</Label>
<Input
type="time"
value={apptForm.watch("endTime") ?? initialEndTime}
onChange={(e) =>
apptForm.setValue("endTime", e.target.value, {
shouldValidate: true,
shouldDirty: true,
})
}
className="rounded-[12px] h-9 text-sm"
/>
</div>
</div>
{apptForm.formState.errors.startTime && (
<p className="text-xs text-destructive">
{apptForm.formState.errors.startTime.message}
</p>
)}
{apptForm.formState.errors.endTime && (
<p className="text-xs text-destructive">
{apptForm.formState.errors.endTime.message}
</p>
)}
</div>
{/* Canal */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">Canal de origen</Label>
<Controller
control={apptForm.control}
name="channel"
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v: string | null) =>
field.onChange((v ?? "web") as AppointmentValues["channel"])
}
>
<SelectTrigger className="rounded-[12px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["whatsapp", "messenger", "instagram", "web", "tiktok", "manual"] as const).map((ch) => (
<SelectItem key={ch} value={ch} className="capitalize">
{ch}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="outline"
className="flex-1 rounded-[12px]"
onClick={closeModal}
>
Cancelar
</Button>
<Button
type="submit"
className="flex-1 bg-primary text-[#0D141D] font-semibold rounded-[12px] hover:bg-primary/90"
disabled={apptForm.formState.isSubmitting}
>
{apptForm.formState.isSubmitting ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Confirmar cita"
)}
</Button>
</div>
</form>
</TabsContent>
{/* Bloquear espacio */}
<TabsContent value="block" className="mt-4">
<form
onSubmit={blockForm.handleSubmit(onBlock)}
className="space-y-4"
>
{/* Selección de días */}
<div className="space-y-1.5">
<Label className="text-sm font-medium flex items-center gap-1.5">
<CalendarDays className="size-3.5 text-muted-foreground" />
Días a bloquear
</Label>
{/* Días ya seleccionados (chips) */}
{selectedDates && selectedDates.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{[...selectedDates]
.sort((a, b) => a.getTime() - b.getTime())
.map((date, i) => (
<span
key={i}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-[8px] bg-muted text-xs font-medium"
>
{format(date, "d MMM", { locale: es })}
<button
type="button"
onClick={() => removeDate(date)}
className="text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="size-3" />
</button>
</span>
))}
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
className="w-full rounded-[12px] text-xs h-8"
onClick={() => setShowCalendar(!showCalendar)}
>
<CalendarDays className="size-3.5 mr-1.5" />
{showCalendar ? "Ocultar calendario" : "Seleccionar días"}
</Button>
{showCalendar && (
<div className="flex justify-center border border-border rounded-[12px] bg-background mt-1">
<DayPicker
mode="multiple"
selected={selectedDates ?? []}
onSelect={(dates) => {
blockForm.setValue("dates", dates ?? [], {
shouldValidate: true,
shouldDirty: true,
});
}}
month={calendarMonth}
onMonthChange={setCalendarMonth}
locale={es}
showOutsideDays={false}
disabled={{ before: new Date() }}
classNames={{
root: "p-3",
months: "flex flex-col gap-3",
month: "flex flex-col gap-3",
nav: "absolute inset-x-0 top-0 flex items-center justify-between gap-1 p-2",
month_caption: "flex justify-center h-8 items-center font-medium text-sm",
weekdays: "flex",
weekday: "flex-1 text-[0.7rem] font-normal text-muted-foreground text-center",
week: "flex w-full mt-1",
day: "flex-1 aspect-square text-center p-0 [&:first-child>button]:rounded-l-[8px] [&:last-child>button]:rounded-r-[8px]",
day_button: "size-full rounded-[8px] text-xs hover:bg-muted transition-colors border-0 bg-transparent",
selected: "bg-primary text-primary-foreground rounded-[8px] hover:bg-primary/90",
today: "font-bold bg-muted/50",
outside: "text-muted-foreground/40",
disabled: "text-muted-foreground/30",
chevron: "size-4",
}}
/>
</div>
)}
{blockForm.formState.errors.dates && (
<p className="text-xs text-destructive">
{blockForm.formState.errors.dates.message}
</p>
)}
</div>
{/* Horario */}
<div className="space-y-1.5">
<Label className="text-sm font-medium flex items-center gap-1.5">
<Clock className="size-3.5 text-muted-foreground" />
Horario del bloqueo
</Label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Hora inicio</Label>
<Input
type="time"
value={selectedStartTime ?? "09:00"}
onChange={(e) =>
blockForm.setValue("startTime", e.target.value, {
shouldValidate: true,
shouldDirty: true,
})
}
className="rounded-[12px] h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Hora fin</Label>
<Input
type="time"
value={selectedEndTime ?? "09:30"}
onChange={(e) =>
blockForm.setValue("endTime", e.target.value, {
shouldValidate: true,
shouldDirty: true,
})
}
className="rounded-[12px] h-9 text-sm"
/>
</div>
</div>
{blockForm.formState.errors.startTime && (
<p className="text-xs text-destructive">
{blockForm.formState.errors.startTime.message}
</p>
)}
{blockForm.formState.errors.endTime && (
<p className="text-xs text-destructive">
{blockForm.formState.errors.endTime.message}
</p>
)}
</div>
{/* Resumen */}
{selectedDates && selectedDates.length > 0 && selectedStartTime && selectedEndTime && (
<div className="rounded-[12px] bg-muted/50 p-3 text-xs text-muted-foreground space-y-0.5">
<p>
<span className="font-medium text-foreground">{selectedDates.length}</span> día(s)
seleccionados
</p>
<p>
Bloque de <span className="font-medium text-foreground">{selectedStartTime}</span> a{" "}
<span className="font-medium text-foreground">{selectedEndTime}</span>
</p>
</div>
)}
{/* Tipo de bloqueo */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">Tipo de bloqueo</Label>
<Controller
control={blockForm.control}
name="blockType"
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v: string | null) =>
field.onChange((v ?? "admin") as BlockValues["blockType"])
}
>
<SelectTrigger className="rounded-[12px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="surgery">🔪 Cirugía</SelectItem>
<SelectItem value="admin">📋 Administrativo</SelectItem>
<SelectItem value="vacation">🏖 Vacaciones</SelectItem>
<SelectItem value="event">🎯 Evento</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="outline"
className="flex-1 rounded-[12px]"
onClick={closeModal}
>
Cancelar
</Button>
<Button
type="submit"
className="flex-1 bg-primary text-[#0D141D] font-semibold rounded-[12px] hover:bg-primary/90"
disabled={blockForm.formState.isSubmitting}
>
{blockForm.formState.isSubmitting ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Bloquear espacio"
)}
</Button>
</div>
</form>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
+219
View File
@@ -0,0 +1,219 @@
"use client";
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { Send, X, Loader2 } from "lucide-react";
import { format } from "date-fns/format";
import { es } from "date-fns/locale/es";
import { createClient } from "@/utils/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import type { Message, Conversation, Patient } from "@/lib/types";
interface ConversationChatProps {
conversation: Conversation;
patient: Patient;
open: boolean;
onClose: () => void;
}
export function ConversationChat({ conversation, patient, open, onClose }: ConversationChatProps) {
const supabase = useMemo(() => createClient(), []);
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [text, setText] = useState("");
const [isSending, setIsSending] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const loadMessages = useCallback(async () => {
const { data } = await supabase
.from("messages")
.select("id, patient_id, conversation_id, channel, direction, type, content, transcript, created_at")
.eq("patient_id", patient.id)
.order("created_at", { ascending: true })
.limit(100);
if (data) setMessages(data as Message[]);
setIsLoading(false);
}, [patient.id, supabase]);
useEffect(() => {
if (open) {
setIsLoading(true);
void loadMessages();
const channel = supabase
.channel(`chat-${conversation.id}`)
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "messages", filter: `patient_id=eq.${patient.id}` },
() => void loadMessages()
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
abortRef.current?.abort();
};
}
}, [open, conversation.id, loadMessages, patient.id, supabase]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = useCallback(async () => {
const trimmed = text.trim();
if (!trimmed || isSending) return;
setIsSending(true);
const phone = patient.phone.replace(/^\+/, "");
// Optimistic: add message to list immediately
const optimisticMsg: Message = {
id: `opt-${Date.now()}`,
patient_id: patient.id,
conversation_id: conversation.id,
channel: conversation.channel,
direction: "out",
type: "text",
content: trimmed,
audio_url: null,
transcript: null,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, optimisticMsg]);
setText("");
try {
abortRef.current?.abort();
abortRef.current = new AbortController();
const res = await fetch("/api/v1/send-message", {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: abortRef.current.signal,
body: JSON.stringify({
number: phone,
text: trimmed,
patientId: patient.id,
conversationId: conversation.id,
}),
});
const result = await res.json();
if (!res.ok) {
toast.error(result.error || "Error al enviar mensaje");
// Remove optimistic message
setMessages((prev) => prev.filter((m) => m.id !== optimisticMsg.id));
setText(trimmed);
}
} catch {
toast.error("Error de conexión al enviar mensaje");
setMessages((prev) => prev.filter((m) => m.id !== optimisticMsg.id));
setText(trimmed);
} finally {
setIsSending(false);
}
}, [conversation.channel, conversation.id, patient.id, patient.phone, text]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}, [handleSend]);
if (!open) return null;
return (
<div className="fixed inset-y-0 right-0 w-96 bg-card border-l border-border shadow-2xl z-50 flex flex-col animate-in slide-in-from-right duration-200">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<div>
<p className="font-heading font-semibold text-sm text-foreground">
{patient.name}
</p>
<p className="text-[10px] text-muted-foreground">
{patient.phone} · {conversation.channel}
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="rounded-[10px]"
aria-label="Cerrar chat"
>
<X className="size-4" />
</Button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
{isLoading ? (
<div className="space-y-3 pt-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className={`h-12 rounded-[12px] ${i % 2 === 0 ? "w-3/4" : "w-2/3 ml-auto"}`} />
))}
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<p className="text-xs text-muted-foreground text-center">
Sin mensajes aún. <br /> Envía el primer mensaje.
</p>
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.direction === "out" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[80%] rounded-[12px] px-3 py-2 text-sm ${
msg.direction === "out"
? "bg-primary/15 text-foreground rounded-br-sm"
: "bg-muted text-foreground rounded-bl-sm"
}`}
>
<p className="leading-relaxed whitespace-pre-wrap break-words">
{msg.content}
</p>
<p className="text-[9px] text-muted-foreground mt-1 text-right">
{format(new Date(msg.created_at), "HH:mm", { locale: es })}
</p>
</div>
</div>
))
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div className="px-4 py-3 border-t border-border shrink-0">
<div className="flex items-center gap-2">
<Input
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Escribe un mensaje..."
className="rounded-[12px] text-sm flex-1"
disabled={isSending}
/>
<Button
size="icon"
onClick={() => void handleSend()}
disabled={!text.trim() || isSending}
className="rounded-[12px] bg-primary text-[#0D141D] hover:bg-primary/80 shrink-0"
>
{isSending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
"use client";
import { useMemo, useCallback } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
CalendarDays,
Users,
BellRing,
Stethoscope,
Settings,
ClipboardList,
LogOut,
Menu,
X,
Zap,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useUIStore } from "@/lib/stores/uiStore";
import { useNotificationStore } from "@/lib/stores/notificationStore";
import { useAuthStore } from "@/lib/stores/authStore";
import { createClient } from "@/utils/supabase/client";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
interface NavItem {
label: string;
href: string;
icon: React.ElementType;
adminOnly?: boolean;
}
const navItems: NavItem[] = [
{ label: "Calendar", href: "/dashboard/calendar", icon: CalendarDays },
{ label: "Pacientes", href: "/dashboard/patients", icon: Users },
{ label: "Citas", href: "/dashboard/appointments", icon: ClipboardList },
{ label: "Notificaciones", href: "/dashboard/notifications", icon: BellRing },
{ label: "Procedimientos", href: "/dashboard/procedures", icon: Stethoscope, adminOnly: true },
{ label: "Configuración", href: "/dashboard/settings", icon: Settings, adminOnly: true },
];
/**
* Sidebar de navegación principal del dashboard.
* Soporta colapso y muestra el badge de notificaciones pendientes.
*/
export function Sidebar() {
const pathname = usePathname();
const router = useRouter();
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const unreadCount = useNotificationStore((s) => s.unreadCount);
const role = useAuthStore((s) => s.role);
const reset = useAuthStore((s) => s.reset);
const supabase = useMemo(() => createClient(), []);
const handleLogout = useCallback(async () => {
const { error } = await supabase.auth.signOut();
if (error) {
toast.error("Error al cerrar sesión");
return;
}
reset();
router.push("/login");
}, [reset, router, supabase]);
return (
<aside
className={cn(
"flex flex-col bg-sidebar border-r border-sidebar-border transition-all duration-300 z-30",
"fixed inset-y-0 left-0 md:relative md:translate-x-0",
sidebarOpen ? "w-60 translate-x-0" : "w-16 -translate-x-full md:translate-x-0"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-5 border-b border-sidebar-border">
{sidebarOpen && (
<Link href="/dashboard" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
<div className="bg-primary rounded-lg p-1.5">
<Zap className="size-4 text-[#0D141D]" />
</div>
<span className="font-heading font-bold text-base text-sidebar-foreground tracking-tight">
EnFlow
</span>
</Link>
)}
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className="text-sidebar-foreground hover:bg-sidebar-accent ml-auto"
aria-label={sidebarOpen ? "Colapsar sidebar" : "Expandir sidebar"}
>
{sidebarOpen ? <X className="size-4" /> : <Menu className="size-4" />}
</Button>
</div>
{/* Nav */}
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto">
{navItems
.filter((item) => !item.adminOnly || role === "admin")
.map((item) => {
const isActive = pathname.startsWith(item.href);
const Icon = item.icon;
const isNotif = item.href === "/dashboard/notifications";
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-[12px] px-3 py-2.5 text-sm font-medium transition-colors",
"text-sidebar-foreground hover:bg-sidebar-accent hover:text-primary",
isActive && "bg-primary/10 text-primary"
)}
title={!sidebarOpen ? item.label : undefined}
>
<div className="relative flex-shrink-0">
<Icon className="size-4" />
{isNotif && unreadCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 size-2 bg-secondary rounded-full" />
)}
</div>
{sidebarOpen && (
<span className="truncate">{item.label}</span>
)}
{sidebarOpen && isNotif && unreadCount > 0 && (
<Badge
variant="secondary"
className="ml-auto text-[10px] px-1.5 py-0 h-5"
>
{unreadCount}
</Badge>
)}
</Link>
);
})}
</nav>
{/* Logout */}
<div className="px-2 py-3 border-t border-sidebar-border">
<button
onClick={handleLogout}
className={cn(
"flex items-center gap-3 rounded-[12px] px-3 py-2.5 text-sm font-medium w-full",
"text-sidebar-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
)}
title={!sidebarOpen ? "Cerrar sesión" : undefined}
>
<LogOut className="size-4 flex-shrink-0" />
{sidebarOpen && <span>Cerrar sesión</span>}
</button>
</div>
</aside>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { Bell, Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useUIStore } from "@/lib/stores/uiStore";
import { useNotificationStore } from "@/lib/stores/notificationStore";
import { useAuthStore } from "@/lib/stores/authStore";
import { UserProfileModal } from "@/components/layout/UserProfileModal";
import { useMemo } from "react";
interface TopbarProps {
title: string;
}
/**
* Topbar del dashboard con título de sección, botón de sidebar móvil y badge de notificaciones.
*/
export function Topbar({ title }: TopbarProps) {
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const openModal = useUIStore((s) => s.openModal);
const unreadCount = useNotificationStore((s) => s.unreadCount);
const user = useAuthStore((s) => s.user);
const initials = useMemo(() => user?.email?.slice(0, 2).toUpperCase() ?? "??", [user?.email]);
return (
<header className="h-16 border-b border-border bg-background/80 backdrop-blur-sm flex items-center px-6 gap-4 sticky top-0 z-20">
{/* Mobile menu toggle */}
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className="md:hidden"
aria-label="Abrir menú"
>
<Menu className="size-4" />
</Button>
<h1 className="font-heading font-semibold text-lg text-foreground truncate flex-1">
{title}
</h1>
<div className="flex items-center gap-3">
{/* Notificaciones */}
<div className="relative">
<Button variant="ghost" size="icon" aria-label="Notificaciones">
<Bell className="size-4" />
</Button>
{unreadCount > 0 && (
<Badge
variant="secondary"
className="absolute -top-1 -right-1 h-4 min-w-4 text-[9px] px-1 flex items-center justify-center"
>
{unreadCount}
</Badge>
)}
</div>
{/* Avatar */}
<button
onClick={() => openModal("editProfile")}
className="size-8 rounded-full bg-primary flex items-center justify-center text-[11px] font-bold text-[#0D141D] hover:bg-primary/80 transition-colors cursor-pointer"
aria-label="Editar perfil"
>
{initials}
</button>
</div>
<UserProfileModal />
</header>
);
}
+212
View File
@@ -0,0 +1,212 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import { useTheme } from "next-themes";
import { Loader2, User, Mail, Shield, Save, Sun, Moon } from "lucide-react";
import { createClient } from "@/utils/supabase/client";
import { useUIStore } from "@/lib/stores/uiStore";
import { useAuthStore } from "@/lib/stores/authStore";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import type { Staff } from "@/lib/types";
const profileSchema = z.object({
name: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
});
type ProfileValues = z.infer<typeof profileSchema>;
export function UserProfileModal() {
const supabase = useMemo(() => createClient(), []);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const user = useAuthStore((s) => s.user);
const role = useAuthStore((s) => s.role);
const { theme, setTheme } = useTheme();
const isOpen = activeModal === "editProfile";
const [staff, setStaff] = useState<Staff | null>(null);
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
const form = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
defaultValues: { name: "" },
});
useEffect(() => {
if (!isOpen || !user) return;
const loadStaff = async () => {
setIsLoadingProfile(true);
const { data } = await supabase
.from("staff")
.select("*")
.eq("id", user.id)
.single();
const staffData = (data ?? null) as Staff | null;
// Prefer staff name, fallback to auth display_name, then to auth email prefix
const displayName =
staffData?.name ||
(user.user_metadata as Record<string, string> | undefined)?.display_name ||
user.email?.split("@")[0] ||
"";
setStaff(staffData);
form.reset({ name: displayName });
setIsLoadingProfile(false);
};
void loadStaff();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, user?.id]);
const onSubmit = async (values: ProfileValues) => {
if (!user) return;
const trimmedName = values.name.trim();
const { error } = await supabase
.from("staff")
.update({ name: trimmedName })
.eq("id", user.id);
if (error) {
toast.error("Error al actualizar el perfil.");
return;
}
// Sync display name to Supabase Auth user metadata
await supabase.auth.updateUser({
data: { display_name: trimmedName },
});
toast.success("Perfil actualizado.");
closeModal();
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
<DialogContent className="max-w-sm rounded-[18px]">
<DialogHeader>
<DialogTitle className="font-heading text-lg font-semibold">
Editar perfil
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
Actualiza tus datos personales
</DialogDescription>
</DialogHeader>
{isLoadingProfile ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 mt-2">
{/* Email (read-only) */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground flex items-center gap-1.5">
<Mail className="size-3" />
Email
</Label>
<Input
value={staff?.email ?? user?.email ?? ""}
disabled
className="rounded-[12px] h-9 text-sm bg-muted/50 cursor-not-allowed"
/>
</div>
{/* Role (read-only) */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground flex items-center gap-1.5">
<Shield className="size-3" />
Rol
</Label>
<Input
value={role ?? ""}
disabled
className="rounded-[12px] h-9 text-sm bg-muted/50 cursor-not-allowed capitalize"
/>
</div>
{/* Name (editable) */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground flex items-center gap-1.5">
<User className="size-3" />
Nombre
</Label>
<Input
{...form.register("name")}
placeholder="Tu nombre completo"
className="rounded-[12px] h-9 text-sm"
autoFocus
/>
{form.formState.errors.name && (
<p className="text-xs text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
{/* Theme toggle */}
<div className="flex items-center justify-between py-2 px-3 rounded-[12px] bg-muted/30">
<div className="flex items-center gap-2">
{theme === "dark" ? (
<Moon className="size-3.5 text-muted-foreground" />
) : (
<Sun className="size-3.5 text-muted-foreground" />
)}
<Label className="text-xs cursor-pointer" onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
Tema oscuro
</Label>
</div>
<Switch
checked={theme === "dark"}
onCheckedChange={(checked) => setTheme(checked ? "dark" : "light")}
aria-label="Cambiar tema"
/>
</div>
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="outline"
className="flex-1 rounded-[12px]"
onClick={closeModal}
>
Cancelar
</Button>
<Button
type="submit"
className="flex-1 bg-primary text-[#0D141D] font-semibold rounded-[12px] hover:bg-primary/90"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<>
<Save className="size-3.5 mr-1.5" />
Guardar
</>
)}
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,73 @@
"use client";
import { useState, memo, useMemo } from "react";
import { createClient } from "@/utils/supabase/client";
import { Badge } from "@/components/ui/badge";
import { Loader2 } from "lucide-react";
const statusColors: Record<string, string> = {
confirmed: "bg-emerald-50 text-emerald-700 border-emerald-200 cursor-pointer hover:bg-emerald-100",
completed: "bg-gray-50 text-gray-600 border-gray-200 cursor-pointer hover:bg-gray-100",
cancelled: "bg-red-50 text-red-700 border-red-200",
reschedule: "bg-yellow-50 text-yellow-700 border-yellow-200",
};
const statusLabel: Record<string, string> = {
confirmed: "Confirmada",
cancelled: "Cancelada",
completed: "Completada",
reschedule: "Reprogramar",
};
interface Props {
slotId: string;
status: string;
onUpdated: () => void;
}
function AppointmentStatusBadge({ slotId, status, onUpdated }: Props) {
const [loading, setLoading] = useState(false);
const supabase = useMemo(() => createClient(), []);
const canToggle = status === "confirmed" || status === "completed";
const handleToggle = async () => {
if (!canToggle || loading) return;
setLoading(true);
const nextStatus = status === "confirmed" ? "completed" : "confirmed";
const { error } = await supabase
.from("slots")
.update({ status: nextStatus })
.eq("id", slotId);
if (error) {
console.error("Status update error:", error.message);
} else {
onUpdated();
}
setLoading(false);
};
return (
<button
type="button"
onClick={handleToggle}
disabled={!canToggle || loading}
className="border-0 bg-transparent p-0"
>
<Badge
variant="outline"
className={`text-[10px] ${statusColors[status] ?? ""}`}
>
{loading ? (
<Loader2 className="size-3 animate-spin" />
) : (
statusLabel[status] ?? status
)}
</Badge>
</button>
);
}
export default memo(AppointmentStatusBadge);
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState, useMemo, useCallback } from "react";
import { createClient } from "@/utils/supabase/client";
import { Card, CardContent } from "@/components/ui/card";
import { format } from "date-fns/format";
import { es } from "date-fns/locale/es";
import AppointmentStatusBadge from "./AppointmentStatusBadge";
import type { Slot } from "@/lib/types";
interface Props {
patientId: string;
}
export function PatientAppointmentsList({ patientId }: Props) {
const supabase = useMemo(() => createClient(), []);
const [appointments, setAppointments] = useState<Slot[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
const { data } = await supabase
.from("slots")
.select("*, procedure:procedures(name)")
.eq("block_type", "appointment")
.eq("patient_id", patientId)
.order("start_at", { ascending: false })
.limit(50);
if (data) setAppointments(data as Slot[]);
setLoading(false);
}, [patientId, supabase]);
useEffect(() => {
void load();
}, [load]);
if (loading) {
return (
<Card className="border-border">
<CardContent className="pt-6">
<p className="text-muted-foreground text-sm text-center py-8">
Cargando citas
</p>
</CardContent>
</Card>
);
}
return (
<Card className="border-border">
<CardContent className="pt-6">
{appointments.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-8">
No hay citas registradas.
</p>
) : (
<div className="space-y-3">
{appointments.map((apt) => (
<div
key={apt.id}
className="flex items-center justify-between p-3 rounded-[12px] bg-muted/30 border border-border/50"
>
<div>
<p className="text-sm font-medium">
{apt.procedure?.name ?? "Consulta general"}
</p>
<p className="text-xs text-muted-foreground">
{apt.start_at
? format(
new Date(apt.start_at),
"EEEE dd MMM yyyy · HH:mm",
{ locale: es }
)
: "—"}
</p>
</div>
<AppointmentStatusBadge
slotId={apt.id}
status={apt.status}
onUpdated={load}
/>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
+227
View File
@@ -0,0 +1,227 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useForm, Controller, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import { Save, Loader2 } from "lucide-react";
import { createClient } from "@/utils/supabase/client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Patient } from "@/lib/types";
const patientSchema = z.object({
name: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
phone: z.string().min(1, "El teléfono es obligatorio"),
email: z.string().email("Email inválido").optional().nullable(),
status: z.enum(["prospecto", "activo", "inactivo"]),
});
type PatientFormValues = z.infer<typeof patientSchema>;
const statusVariant: Record<string, string> = {
activo: "text-emerald-700 bg-emerald-50 border-emerald-200",
prospecto: "text-yellow-700 bg-yellow-50 border-yellow-200",
inactivo: "text-gray-600 bg-gray-50 border-gray-200",
};
const statusLabel: Record<string, string> = {
activo: "Activo",
prospecto: "Prospecto",
inactivo: "Inactivo",
};
interface PatientEditorProps {
patient: Patient;
}
export function PatientEditor({ patient }: PatientEditorProps) {
const router = useRouter();
const supabase = useMemo(() => createClient(), []);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<PatientFormValues>({
resolver: zodResolver(patientSchema),
defaultValues: {
name: patient.name,
phone: patient.phone,
email: patient.email,
status: patient.status,
},
mode: "onChange",
});
const { errors, isDirty } = form.formState;
const watchedStatus = useWatch({ control: form.control, name: "status" });
const onSubmit = useCallback(async (values: PatientFormValues) => {
setIsSubmitting(true);
const { error } = await supabase
.from("patients")
.update({
name: values.name.trim(),
phone: values.phone.trim(),
email: values.email?.trim().toLowerCase() ?? null,
status: values.status,
})
.eq("id", patient.id);
if (error) {
toast.error("Error al guardar los cambios.");
console.error("Patient update error:", (error as Error)?.message ?? "unknown");
} else {
toast.success("Datos del paciente actualizados.");
router.refresh();
}
setIsSubmitting(false);
}, [patient.id, router, supabase]);
return (
<Card className="border-border">
<CardContent className="pt-6">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
{/* Nombre */}
<div className="space-y-1.5">
<Label htmlFor="patient-name" className="text-sm font-medium">
Nombre completo
</Label>
<Input
id="patient-name"
{...form.register("name")}
placeholder="Nombre del paciente"
className="rounded-[12px]"
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
{/* Teléfono */}
<div className="space-y-1.5">
<Label htmlFor="patient-phone" className="text-sm font-medium">
Teléfono
</Label>
<Input
id="patient-phone"
{...form.register("phone")}
placeholder="+57 300 000 0000"
className="rounded-[12px]"
/>
{errors.phone && (
<p className="text-xs text-destructive">{errors.phone.message}</p>
)}
</div>
{/* Email */}
<div className="space-y-1.5">
<Label htmlFor="patient-email" className="text-sm font-medium">
Email
</Label>
<Input
id="patient-email"
type="email"
{...form.register("email")}
placeholder="paciente@email.com"
className="rounded-[12px]"
/>
{errors.email && (
<p className="text-xs text-destructive">{errors.email.message}</p>
)}
</div>
</div>
{/* Estado */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">Estado del paciente</Label>
<div className="flex items-center gap-4">
<Controller
control={form.control}
name="status"
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v: string | null) => {
if (v) field.onChange(v);
}}
>
<SelectTrigger className="rounded-[12px] w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="activo">Activo</SelectItem>
<SelectItem value="prospecto">Prospecto</SelectItem>
<SelectItem value="inactivo">Inactivo</SelectItem>
</SelectContent>
</Select>
)}
/>
<Badge
variant="outline"
className={`capitalize text-xs ${statusVariant[watchedStatus] ?? ""}`}
>
{statusLabel[watchedStatus] ?? patient.status}
</Badge>
</div>
</div>
{/* Fechas (readonly) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Creado
</p>
<p className="text-sm font-medium text-foreground">
{new Date(patient.created_at).toLocaleDateString("es-CO", {
day: "numeric",
month: "short",
year: "numeric",
})}
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Actualizado
</p>
<p className="text-sm font-medium text-foreground">
{new Date(patient.updated_at ?? patient.created_at).toLocaleDateString("es-CO", {
day: "numeric",
month: "short",
year: "numeric",
})}
</p>
</div>
</div>
{/* Submit */}
<div className="flex gap-2 pt-2">
<Button
type="submit"
className="bg-primary text-[#0D141D] font-semibold rounded-[12px] hover:bg-primary/90"
disabled={isSubmitting || !isDirty}
>
{isSubmitting ? (
<Loader2 className="size-4 animate-spin mr-2" />
) : (
<Save className="size-4 mr-2" />
)}
Guardar cambios
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
import { useState, useMemo, memo } from "react";
import { toast } from "sonner";
import { Loader2, Pencil, Save, X } from "lucide-react";
import { createClient } from "@/utils/supabase/client";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
interface PatientNotesProps {
profileId: string;
initialNotes: string | null;
}
export const PatientNotes = memo(function PatientNotes({ profileId, initialNotes }: PatientNotesProps) {
const supabase = useMemo(() => createClient(), []);
const [isEditing, setIsEditing] = useState(false);
const [notes, setNotes] = useState(initialNotes ?? "");
const [draft, setDraft] = useState(initialNotes ?? "");
const [isSaving, setIsSaving] = useState(false);
const handleEdit = () => {
setDraft(notes);
setIsEditing(true);
};
const handleCancel = () => {
setDraft(notes);
setIsEditing(false);
};
const handleSave = async () => {
setIsSaving(true);
const trimmed = draft.trim();
const { error } = await supabase
.from("patient_profiles")
.update({ notes: trimmed || null })
.eq("id", profileId);
if (error) {
toast.error("Error al guardar las notas.");
} else {
setNotes(trimmed);
setIsEditing(false);
toast.success("Notas guardadas.");
}
setIsSaving(false);
};
if (!isEditing) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">
Notas Clínicas
</p>
<Button
variant="ghost"
size="sm"
className="h-7 rounded-[8px] text-xs gap-1"
onClick={handleEdit}
>
<Pencil className="size-3" />
Editar
</Button>
</div>
<p className="text-sm text-muted-foreground whitespace-pre-wrap min-h-[60px]">
{notes || "No hay notas clínicas registradas."}
</p>
</div>
);
}
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">
Editando notas
</p>
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Escribe las notas clínicas del paciente…"
className="min-h-[120px] rounded-[12px] text-sm resize-y"
autoFocus
/>
<div className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
className="h-8 rounded-[10px] text-xs gap-1"
onClick={handleCancel}
>
<X className="size-3" />
Cancelar
</Button>
<Button
size="sm"
className="h-8 rounded-[10px] text-xs gap-1 bg-primary text-[#0D141D] font-semibold hover:bg-primary/90"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Save className="size-3" />
)}
Guardar
</Button>
</div>
</div>
);
});
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { ThemeProvider } from "next-themes";
export function ThemeWrapper({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange
>
{children}
</ThemeProvider>
);
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useEffect, useMemo } from "react";
import { createClient } from "@/utils/supabase/client";
import { useAuthStore } from "@/lib/stores/authStore";
import { useNotificationStore } from "@/lib/stores/notificationStore";
import type { Conversation } from "@/lib/types";
/**
* Proveedor global React que:
* 1. Sincroniza la sesión de Supabase Auth con authStore.
* 2. Suscribe a Supabase Realtime en conversations.
*/
export function Providers({ children }: { children: React.ReactNode }) {
const { setUser, setSession, setLoading } = useAuthStore();
const { setPendingConversations, addPendingConversation } = useNotificationStore();
const supabase = useMemo(() => createClient(), []);
useEffect(() => {
// Sincronizar sesión inicial
const init = async () => {
setLoading(true);
const { data } = await supabase.auth.getSession();
setSession(data.session);
setUser(data.session?.user ?? null);
setLoading(false);
};
void init();
// Listener para cambios de auth
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session);
setUser(session?.user ?? null);
}
);
return () => subscription.unsubscribe();
}, [setSession, setUser, setLoading, supabase]);
useEffect(() => {
// Cargar conversaciones pendientes iniciales
const loadPending = async () => {
const { data } = await supabase
.from("conversations")
.select("*, patient:patients(id, name, phone)")
.eq("status", "pending_human")
.order("last_message_at", { ascending: false });
if (data) setPendingConversations(data as Conversation[]);
};
void loadPending();
// Suscripción Realtime a conversaciones (INSERT y UPDATE)
const channel = supabase
.channel("conversations-realtime")
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "conversations",
filter: "status=eq.pending_human",
},
(payload) => {
addPendingConversation(payload.new as Conversation);
}
)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "conversations",
filter: "status=eq.pending_human",
},
(payload) => {
addPendingConversation(payload.new as Conversation);
}
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [setPendingConversations, addPendingConversation, supabase]);
return <>{children}</>;
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+224
View File
@@ -0,0 +1,224 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
value,
onValueChange,
defaultValue,
children,
...props
}: SelectPrimitive.Root.Props<string> & {
onValueChange?: (value: string | null) => void
}) {
return (
<SelectPrimitive.Root
value={value}
defaultValue={defaultValue}
onValueChange={(newValue) => {
if (onValueChange) {
onValueChange(Array.isArray(newValue) ? newValue[0] ?? null : newValue)
}
}}
{...props}
>
{children}
</SelectPrimitive.Root>
)
}
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
Regular → Executable
View File
Regular → Executable
View File
+82
View File
@@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }