fix: fechas utc to utc + 4
This commit is contained in:
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user