Añadido Dockerfil docker-compose.yml
This commit is contained in:
Executable
+710
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+156
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+73
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+91
@@ -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}</>;
|
||||
}
|
||||
Executable
+52
@@ -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
Regular → Executable
Regular → Executable
Executable
+160
@@ -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,
|
||||
}
|
||||
Executable
+20
@@ -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 }
|
||||
Executable
+18
@@ -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 }
|
||||
Executable
+224
@@ -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,
|
||||
}
|
||||
Executable
+13
@@ -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
Regular → Executable
Executable
+82
@@ -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 }
|
||||
Executable
+18
@@ -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 }
|
||||
Reference in New Issue
Block a user