fix: fechas utc to utc + 4

This commit is contained in:
2026-06-13 08:56:02 -05:00
parent 9c760a6126
commit 3e2843ac57
2269 changed files with 26421 additions and 637244 deletions
+2 -2
View File
@@ -60,10 +60,10 @@ describe("PatientSchema", () => {
expect(result.success).toBe(false);
});
it("accepts null email and phone", () => {
it("accepts null email and valid phone", () => {
const result = PatientSchema.safeParse({
name: "María",
phone: null,
phone: "+57 300 123 4567",
email: null,
status: "prospecto",
});
+2 -9
View File
@@ -5,8 +5,7 @@ import { createClient } from "@/utils/supabase/client";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { format } from "date-fns/format";
import { es } from "date-fns/locale/es";
import { formatPgTime } from "@/lib/utils";
import type { Slot } from "@/lib/types";
const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
@@ -117,13 +116,7 @@ export default function AppointmentsPage() {
{apt.channel}
</td>
<td className="py-3 pr-4 text-foreground">
{apt.start_at
? format(
new Date(apt.start_at),
"dd MMM yyyy · HH:mm",
{ locale: es }
)
: "—"}
{apt.start_at ? formatPgTime(apt.start_at, "dd MMM yyyy · HH:mm") : "—"}
</td>
<td className="py-3">
<Badge
+7 -12
View File
@@ -14,6 +14,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { format } from "date-fns/format";
import { es } from "date-fns/locale/es";
import { formatPgTime } from "@/lib/utils";
export const dynamic = "force-dynamic";
@@ -41,10 +42,8 @@ const CHANNEL_LABELS: Record<string, string> = {
async function getDashboardData() {
const supabase = await createClient();
const now = new Date();
const offset = -5 * 60;
const local = new Date(now.getTime() + now.getTimezoneOffset() * 60000 + offset * 60000);
const today = local.toISOString().split("T")[0];
// Fecha en zona horaria de Colombia (UTC-5) sin depender del TZ del servidor
const today = new Date().toLocaleDateString("en-CA", { timeZone: "America/Bogota" });
const [
{ data: todayApps },
@@ -59,8 +58,8 @@ async function getDashboardData() {
.from("slots")
.select("*, patient:patients(name, phone), procedure:procedures(name)")
.eq("block_type", "appointment")
.gte("start_at", `${today}T00:00:00Z`)
.lt("start_at", `${today}T23:59:59Z`)
.gte("start_at", `${today}T00:00:00`)
.lt("start_at", `${today}T23:59:59`)
.order("start_at", { ascending: true }),
supabase.from("patients").select("*", { count: "exact", head: true }),
@@ -225,14 +224,10 @@ export default async function DashboardPage() {
{/* Hora */}
<div className="w-16 shrink-0 text-center">
<p className="text-sm font-heading font-bold text-foreground">
{apt.start_at
? format(new Date(apt.start_at), "HH:mm")
: "—:—"}
{apt.start_at ? formatPgTime(apt.start_at, "HH:mm") : "—:—"}
</p>
<p className="text-[10px] text-muted-foreground">
{apt.start_at
? format(new Date(apt.start_at), "haaa").replace(". m.", "m")
: ""}
{apt.start_at ? formatPgTime(apt.start_at, "haaa") : ""}
</p>
</div>
+17 -1
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { Search, UserCheck, UserX, Clock } from "lucide-react";
import { Search, UserCheck, UserX, Clock, Plus } from "lucide-react";
import { createClient } from "@/utils/supabase/client";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -13,6 +13,8 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { CreatePatientModal } from "@/components/patients/CreatePatientModal";
import type { Patient } from "@/lib/types";
const statusConfig = {
@@ -30,6 +32,7 @@ export default function PatientsPage() {
const [patients, setPatients] = useState<Patient[]>([]);
const [query, setQuery] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -90,6 +93,13 @@ export default function PatientsPage() {
Gestiona los perfiles y el historial clínico
</p>
</div>
<Button
className="bg-primary text-[#0D141D] font-semibold rounded-[12px] hover:bg-primary/90"
onClick={() => setIsCreateOpen(true)}
>
<Plus className="size-4 mr-1.5" />
Nuevo paciente
</Button>
</div>
{/* Búsqueda */}
@@ -164,6 +174,12 @@ export default function PatientsPage() {
{patients.length} paciente{patients.length !== 1 ? "s" : ""} mostrados
</p>
)}
<CreatePatientModal
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
onSuccess={() => fetchPatients("")}
/>
</div>
);
}
+6 -8
View File
@@ -20,6 +20,7 @@ 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 { toColombiaISOString } from "@/lib/utils";
import { useUIStore } from "@/lib/stores/uiStore";
import {
Dialog,
@@ -234,11 +235,10 @@ export function SlotActionModal({ onSuccess }: SlotActionModalProps) {
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);
const dateStr = format(date, "yyyy-MM-dd");
return {
start_at: startAt.toISOString(),
end_at: endAt.toISOString(),
start_at: toColombiaISOString(dateStr, startH, startM),
end_at: toColombiaISOString(dateStr, endH, endM),
block_type: values.blockType,
status: "blocked",
};
@@ -271,12 +271,10 @@ export function SlotActionModal({ onSuccess }: SlotActionModalProps) {
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(),
start_at: toColombiaISOString(dateStr, startH, startM),
end_at: toColombiaISOString(dateStr, endH, endM),
block_type: "appointment",
patient_id: values.patientId,
procedure_id: values.procedureId,
@@ -0,0 +1,202 @@
"use client";
import { useState, useMemo, useCallback } 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, Plus, UserPlus } from "lucide-react";
import { createClient } from "@/utils/supabase/client";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PatientFormSchema } from "@/lib/validations/schemas";
type CreatePatientFormValues = z.infer<typeof PatientFormSchema>;
interface CreatePatientModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function CreatePatientModal({ open, onOpenChange, onSuccess }: CreatePatientModalProps) {
const supabase = useMemo(() => createClient(), []);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<CreatePatientFormValues>({
resolver: zodResolver(PatientFormSchema),
defaultValues: {
name: "",
phone: "",
email: null,
status: "prospecto",
},
mode: "onChange",
});
const { errors, isDirty } = form.formState;
const onSubmit = useCallback(
async (values: CreatePatientFormValues) => {
setIsSubmitting(true);
const { error } = await supabase.from("patients").insert({
name: values.name.trim(),
phone: values.phone.trim(),
email: values.email?.trim().toLowerCase() ?? null,
status: values.status,
});
if (error) {
toast.error("Error al crear el paciente.");
console.error("Patient insert error:", (error as Error)?.message ?? "unknown");
} else {
toast.success("Paciente creado correctamente.");
form.reset();
onOpenChange(false);
onSuccess();
}
setIsSubmitting(false);
},
[supabase, form, onOpenChange, onSuccess]
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md rounded-[18px]">
<DialogHeader>
<DialogTitle className="font-heading text-lg font-semibold flex items-center gap-2">
<UserPlus className="size-5" />
Nuevo paciente
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
Completa los datos para registrar un paciente nuevo.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 mt-2">
{/* Nombre */}
<div className="space-y-1.5">
<Label htmlFor="new-patient-name" className="text-sm font-medium">
Nombre completo
</Label>
<Input
id="new-patient-name"
{...form.register("name")}
placeholder="Nombre del paciente"
className="rounded-[12px]"
autoFocus
/>
{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="new-patient-phone" className="text-sm font-medium">
Teléfono
</Label>
<Input
id="new-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="new-patient-email" className="text-sm font-medium">
Email
</Label>
<Input
id="new-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</Label>
<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="prospecto">Prospecto</SelectItem>
<SelectItem value="activo">Activo</SelectItem>
<SelectItem value="inactivo">Inactivo</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
{/* Botones */}
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="outline"
className="flex-1 rounded-[12px]"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button
type="submit"
className="flex-1 bg-primary text-[#0D141D] font-semibold rounded-[12px] hover:bg-primary/90"
disabled={isSubmitting || !isDirty}
>
{isSubmitting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<>
<Plus className="size-4 mr-1.5" />
Crear
</>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -3,8 +3,7 @@
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 { formatPgTime } from "@/lib/utils";
import AppointmentStatusBadge from "./AppointmentStatusBadge";
import type { Slot } from "@/lib/types";
@@ -65,13 +64,7 @@ export function PatientAppointmentsList({ patientId }: Props) {
{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 }
)
: "—"}
{apt.start_at ? formatPgTime(apt.start_at, "dd MMM yyyy · HH:mm") : "—"}
</p>
</div>
<AppointmentStatusBadge
+3 -9
View File
@@ -20,16 +20,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PatientFormSchema } from "@/lib/validations/schemas";
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>;
type PatientFormValues = z.infer<typeof PatientFormSchema>;
const statusVariant: Record<string, string> = {
activo: "text-emerald-700 bg-emerald-50 border-emerald-200",
@@ -53,7 +47,7 @@ export function PatientEditor({ patient }: PatientEditorProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<PatientFormValues>({
resolver: zodResolver(patientSchema),
resolver: zodResolver(PatientFormSchema),
defaultValues: {
name: patient.name,
phone: patient.phone,
+36
View File
@@ -4,3 +4,39 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Crea un timestamp ISO con offset explícito de Colombia (UTC-5).
* Útil para insertar en PostgreSQL TIMESTAMPTZ sin depender del TZ del navegador.
*/
export function toColombiaISOString(dateStr: string, hour: number, minute: number): string {
// Construye string ISO con offset explícito de Colombia
const iso = `${dateStr}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00-05:00`;
return new Date(iso).toISOString();
}
/**
* Formatea un timestamp de PostgreSQL/Supabase sin conversiones de zona horaria.
* Asume que el valor ya está en la zona horaria deseada.
*/
export function formatPgTime(ts: string, pattern: "HH:mm" | "dd MMM yyyy · HH:mm" | "haaa"): string {
const clean = ts.replace(" ", "T");
const datePart = clean.slice(0, 10); // YYYY-MM-DD
const hour = clean.slice(11, 13);
const minute = clean.slice(14, 16);
const timePart = `${hour}:${minute}`;
if (pattern === "HH:mm") return timePart;
if (pattern === "haaa") {
const h = parseInt(hour, 10);
const ampm = h < 12 ? "a. m." : "p. m.";
const h12 = h % 12 || 12;
return `${h12} ${ampm}`;
}
const [year, month, day] = datePart.split("-");
const months = ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"];
const monthAbbr = months[parseInt(month, 10) - 1];
return `${day} ${monthAbbr} ${year} · ${timePart}`;
}
+8 -1
View File
@@ -28,11 +28,18 @@ const phoneRegex = /^[+]?[\d\s\-().]{7,30}$/;
export const PatientSchema = z.object({
id: z.string().uuid().optional(),
name: z.string().min(2, "Name must be at least 2 characters").max(100, "Name too long").trim(),
phone: z.string().regex(phoneRegex, "Invalid phone format").max(30).optional().nullable(),
phone: z.string().regex(phoneRegex, "Invalid phone format").max(30),
email: z.string().email("Invalid email").max(255).optional().nullable(),
status: patientStatusEnum.default("prospecto"),
});
export const PatientFormSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters").max(100, "Name too long").trim(),
phone: z.string().regex(phoneRegex, "Invalid phone format").max(30),
email: z.string().email("Invalid email").max(255).nullable(),
status: patientStatusEnum,
});
export const PatientProfileSchema = z.object({
id: z.string().uuid().optional(),
patient_id: z.string().uuid(),