develop #83

Merged
M-Gabrielly merged 426 commits from develop into main 2025-12-04 04:13:15 +00:00
4 changed files with 664 additions and 27 deletions
Showing only changes of commit 075fa92eb9 - Show all commits

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback } from "react";
import { import {
MoreHorizontal, MoreHorizontal,
PlusCircle, PlusCircle,
@ -409,7 +409,7 @@ export default function ConsultasPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/agenda"> <Link href="/agenda">
<Button size="sm" className="h-8 gap-1"> <Button size="sm" className="h-8 gap-1 bg-blue-600">
<PlusCircle className="h-3.5 w-3.5" /> <PlusCircle className="h-3.5 w-3.5" />
<span className="sr-only sm:not-sr-only sm:whitespace-nowrap">Agendar Nova Consulta</span> <span className="sr-only sm:not-sr-only sm:whitespace-nowrap">Agendar Nova Consulta</span>
</Button> </Button>

View File

@ -5,6 +5,8 @@ import { CalendarRegistrationForm } from "@/components/forms/calendar-registrati
import HeaderAgenda from "@/components/agenda/HeaderAgenda"; import HeaderAgenda from "@/components/agenda/HeaderAgenda";
import FooterAgenda from "@/components/agenda/FooterAgenda"; import FooterAgenda from "@/components/agenda/FooterAgenda";
import { useState } from "react"; import { useState } from "react";
import { criarAgendamento } from '@/lib/api';
import { toast } from '@/hooks/use-toast';
interface FormData { interface FormData {
patientName?: string; patientName?: string;
@ -37,9 +39,33 @@ export default function NovoAgendamentoPage() {
}; };
const handleSave = () => { const handleSave = () => {
console.log("Salvando novo agendamento...", formData); (async () => {
alert("Novo agendamento salvo (simulado)!"); try {
router.push("/consultas"); // basic validation
if (!formData.patientId && !(formData as any).patient_id) throw new Error('Patient ID é obrigatório');
if (!formData.doctorId && !(formData as any).doctor_id) throw new Error('Doctor ID é obrigatório');
if (!formData.appointmentDate) throw new Error('Data é obrigatória');
if (!formData.startTime) throw new Error('Horário de início é obrigatório');
const payload: any = {
patient_id: formData.patientId || (formData as any).patient_id,
doctor_id: formData.doctorId || (formData as any).doctor_id,
scheduled_at: new Date(`${formData.appointmentDate}T${formData.startTime}`).toISOString(),
duration_minutes: formData.duration_minutes ?? 30,
appointment_type: formData.appointmentType ?? 'presencial',
chief_complaint: formData.chief_complaint ?? null,
patient_notes: formData.patient_notes ?? null,
insurance_provider: formData.insurance_provider ?? null,
};
await criarAgendamento(payload);
// success
try { toast({ title: 'Agendamento criado', description: 'O agendamento foi criado com sucesso.' }); } catch {}
router.push('/consultas');
} catch (err: any) {
alert(err?.message ?? String(err));
}
})();
}; };
const handleCancel = () => { const handleCancel = () => {
@ -50,10 +76,11 @@ export default function NovoAgendamentoPage() {
<div className="min-h-screen flex flex-col bg-background"> <div className="min-h-screen flex flex-col bg-background">
<HeaderAgenda /> <HeaderAgenda />
<main className="flex-1 mx-auto w-full max-w-7xl px-8 py-8"> <main className="flex-1 mx-auto w-full max-w-7xl px-8 py-8">
<CalendarRegistrationForm <CalendarRegistrationForm
formData={formData} formData={formData}
onFormChange={handleFormChange} onFormChange={handleFormChange}
/> createMode
/>
</main> </main>
<FooterAgenda onSave={handleSave} onCancel={handleCancel} /> <FooterAgenda onSave={handleSave} onCancel={handleCancel} />
</div> </div>

View File

@ -2,10 +2,12 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { buscarPacientePorId } from "@/lib/api"; import { buscarPacientePorId, listarMedicos, buscarPacientesPorMedico, getAvailableSlots, buscarPacientes, listarPacientes, listarDisponibilidades } from "@/lib/api";
import { listAssignmentsForPatient } from "@/lib/assignment";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { Calendar, Search, ChevronDown } from "lucide-react"; import { Calendar, Search, ChevronDown } from "lucide-react";
interface FormData { interface FormData {
@ -21,6 +23,8 @@ interface FormData {
validade?: string; validade?: string;
documentos?: string; documentos?: string;
professionalName?: string; professionalName?: string;
patientId?: string | null;
doctorId?: string | null;
unit?: string; unit?: string;
appointmentDate?: string; appointmentDate?: string;
startTime?: string; startTime?: string;
@ -43,6 +47,7 @@ interface FormData {
interface CalendarRegistrationFormProperties { interface CalendarRegistrationFormProperties {
formData: FormData; formData: FormData;
onFormChange: (data: FormData) => void; onFormChange: (data: FormData) => void;
createMode?: boolean; // when true, enable fields needed to create a new appointment
} }
const formatValidityDate = (value: string) => { const formatValidityDate = (value: string) => {
@ -56,10 +61,21 @@ const formatValidityDate = (value: string) => {
return cleaned; return cleaned;
}; };
export function CalendarRegistrationForm({ formData, onFormChange }: CalendarRegistrationFormProperties) { export function CalendarRegistrationForm({ formData, onFormChange, createMode = false }: CalendarRegistrationFormProperties) {
const [isAdditionalInfoOpen, setIsAdditionalInfoOpen] = useState(false); const [isAdditionalInfoOpen, setIsAdditionalInfoOpen] = useState(false);
const [patientDetails, setPatientDetails] = useState<any | null>(null); const [patientDetails, setPatientDetails] = useState<any | null>(null);
const [loadingPatient, setLoadingPatient] = useState(false); const [loadingPatient, setLoadingPatient] = useState(false);
const [doctorOptions, setDoctorOptions] = useState<any[]>([]);
const [filteredDoctorOptions, setFilteredDoctorOptions] = useState<any[] | null>(null);
const [patientOptions, setPatientOptions] = useState<any[]>([]);
const [patientSearch, setPatientSearch] = useState('');
const searchTimerRef = useRef<any>(null);
const [loadingDoctors, setLoadingDoctors] = useState(false);
const [loadingPatients, setLoadingPatients] = useState(false);
const [loadingAssignedDoctors, setLoadingAssignedDoctors] = useState(false);
const [loadingPatientsForDoctor, setLoadingPatientsForDoctor] = useState(false);
const [availableSlots, setAvailableSlots] = useState<Array<{ datetime: string; available: boolean }>>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
// Helpers to convert between ISO (server) and input[type=datetime-local] value // Helpers to convert between ISO (server) and input[type=datetime-local] value
const isoToDatetimeLocal = (iso?: string | null) => { const isoToDatetimeLocal = (iso?: string | null) => {
@ -136,6 +152,288 @@ export function CalendarRegistrationForm({ formData, onFormChange }: CalendarReg
}; };
}, [(formData as any).patientId, (formData as any).patient_id]); }, [(formData as any).patientId, (formData as any).patient_id]);
// Load doctor suggestions (simple listing) when the component mounts
useEffect(() => {
let mounted = true;
(async () => {
setLoadingDoctors(true);
try {
// listarMedicos returns a paginated list of doctors; request a reasonable limit
const docs = await listarMedicos({ limit: 200 });
if (!mounted) return;
setDoctorOptions(docs || []);
} catch (e) {
console.warn('[CalendarRegistrationForm] falha ao carregar médicos', e);
} finally {
if (!mounted) return;
setLoadingDoctors(false);
}
})();
return () => { mounted = false; };
}, []);
// Preload patients so the patient <select> always has options on open
useEffect(() => {
let mounted = true;
(async () => {
setLoadingPatients(true);
try {
// request a reasonable number of patients for the select; adjust limit if needed
const pats = await listarPacientes({ limit: 200 });
if (!mounted) return;
setPatientOptions(pats || []);
} catch (err) {
console.warn('[CalendarRegistrationForm] falha ao carregar pacientes', err);
if (!mounted) return;
setPatientOptions([]);
} finally {
if (!mounted) return;
setLoadingPatients(false);
}
})();
return () => { mounted = false; };
}, []);
// When a patient is selected, filter doctorOptions to only doctors assigned to that patient
useEffect(() => {
const patientId = (formData as any).patientId || (formData as any).patient_id || null;
if (!patientId) {
// clear filter when no patient selected
setFilteredDoctorOptions(null);
setLoadingAssignedDoctors(false);
return;
}
let mounted = true;
setLoadingAssignedDoctors(true);
(async () => {
try {
// listAssignmentsForPatient returns rows with user_id (the auth user assigned)
const assignments = await listAssignmentsForPatient(String(patientId));
if (!mounted) return;
const userIds = Array.from(new Set((assignments || []).map((a: any) => a.user_id).filter(Boolean)));
if (!userIds.length) {
// no assignments -> fallback to full list but keep a non-null marker
setFilteredDoctorOptions([]);
return;
}
// Filter already-loaded doctorOptions by matching user_id
// If doctorOptions isn't loaded yet, we'll wait for it (doctorOptions effect will run first on mount)
const matched = (doctorOptions || []).filter((d) => userIds.includes(String(d.user_id)));
if (mounted) setFilteredDoctorOptions(matched);
} catch (err) {
console.warn('[CalendarRegistrationForm] falha ao carregar médicos atribuídos ao paciente', err);
if (mounted) setFilteredDoctorOptions([]);
} finally {
if (mounted) setLoadingAssignedDoctors(false);
}
})();
return () => { mounted = false; };
}, [(formData as any).patientId, (formData as any).patient_id, doctorOptions]);
// When doctorId changes, load patients assigned to that doctor
useEffect(() => {
const docId = (formData as any).doctorId || (formData as any).doctor_id || null;
if (!docId) {
setPatientOptions([]);
return;
}
let mounted = true;
setLoadingPatientsForDoctor(true);
(async () => {
try {
const pats = await buscarPacientesPorMedico(String(docId));
if (!mounted) return;
setPatientOptions(pats || []);
} catch (e) {
console.warn('[CalendarRegistrationForm] falha ao carregar pacientes por médico', e);
if (!mounted) return;
setPatientOptions([]);
} finally {
if (!mounted) return;
setLoadingPatientsForDoctor(false);
}
})();
return () => { mounted = false; };
}, [(formData as any).doctorId, (formData as any).doctor_id]);
// When doctor or date changes, fetch available slots
useEffect(() => {
const docId = (formData as any).doctorId || (formData as any).doctor_id || null;
const date = (formData as any).appointmentDate || null;
if (!docId || !date) {
setAvailableSlots([]);
return;
}
let mounted = true;
setLoadingSlots(true);
(async () => {
try {
console.debug('[CalendarRegistrationForm] getAvailableSlots - params', { docId, date, appointmentType: formData.appointmentType });
console.debug('[CalendarRegistrationForm] doctorOptions count', (doctorOptions || []).length, 'selectedDoctorId', docId, 'doctorOptions sample', (doctorOptions || []).slice(0,3));
// Build start/end as local day bounds from YYYY-MM-DD to avoid
// timezone/parsing issues (sending incorrect UTC offsets that shift
// the requested day to the previous/next calendar day).
// Expect `date` to be in format 'YYYY-MM-DD'. Parse explicitly.
let start: Date;
let end: Date;
try {
const parts = String(date).split('-').map((p) => Number(p));
if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) {
const [y, m, d] = parts;
// new Date(y, m-1, d, hh, mm, ss, ms) constructs a date in the
// local timezone at the requested hour. toISOString() will then
// represent that local instant in UTC which is what the server
// expects for availability checks across timezones.
start = new Date(y, m - 1, d, 0, 0, 0, 0);
end = new Date(y, m - 1, d, 23, 59, 59, 999);
} else {
// fallback to previous logic if parsing fails
start = new Date(date);
start.setHours(0,0,0,0);
end = new Date(date);
end.setHours(23,59,59,999);
}
} catch (err) {
// fallback safe behavior
start = new Date(date);
start.setHours(0,0,0,0);
end = new Date(date);
end.setHours(23,59,59,999);
}
const av = await getAvailableSlots({ doctor_id: String(docId), start_date: start.toISOString(), end_date: end.toISOString(), appointment_type: formData.appointmentType || 'presencial' });
if (!mounted) return;
console.debug('[CalendarRegistrationForm] getAvailableSlots - response slots count', (av && av.slots && av.slots.length) || 0, av);
// Try to restrict the returned slots to the doctor's public availability windows
try {
const disponibilidades = await listarDisponibilidades({ doctorId: String(docId) }).catch(() => []);
const weekdayNumber = start.getDay(); // 0 (Sun) .. 6 (Sat)
// map weekday number to possible representations (numeric, en, pt, abbrev)
const weekdayNames: Record<number, string[]> = {
0: ['0', 'sun', 'sunday', 'domingo'],
1: ['1', 'mon', 'monday', 'segunda', 'segunda-feira'],
2: ['2', 'tue', 'tuesday', 'terca', 'terça', 'terça-feira'],
3: ['3', 'wed', 'wednesday', 'quarta', 'quarta-feira'],
4: ['4', 'thu', 'thursday', 'quinta', 'quinta-feira'],
5: ['5', 'fri', 'friday', 'sexta', 'sexta-feira'],
6: ['6', 'sat', 'saturday', 'sabado', 'sábado']
};
const allowed = (weekdayNames[weekdayNumber] || []).map((s) => String(s).toLowerCase());
// Filter disponibilidades to those matching the weekday (try multiple fields)
const matched = (disponibilidades || []).filter((d: any) => {
try {
const raw = String(d.weekday ?? d.weekday_name ?? d.day ?? d.day_of_week ?? '').toLowerCase();
if (!raw) return false;
// direct numeric or name match
if (allowed.includes(raw)) return true;
// sometimes API returns numbers as integers
if (typeof d.weekday === 'number' && d.weekday === weekdayNumber) return true;
if (typeof d.day_of_week === 'number' && d.day_of_week === weekdayNumber) return true;
return false;
} catch (e) { return false; }
});
console.debug('[CalendarRegistrationForm] disponibilidades fetched', disponibilidades, 'matched for weekday', weekdayNumber, matched);
if (matched && matched.length) {
// Build windows from matched disponibilidades and filter av.slots
const windows = matched.map((d: any) => {
// d.start_time may be '09:00:00' or '09:00'
const parseTime = (t?: string) => {
if (!t) return { hh: 0, mm: 0, ss: 0 };
const parts = String(t).split(':').map((p) => Number(p));
return { hh: parts[0] || 0, mm: parts[1] || 0, ss: parts[2] || 0 };
};
const s = parseTime(d.start_time);
const e2 = parseTime(d.end_time);
const winStart = new Date(start.getFullYear(), start.getMonth(), start.getDate(), s.hh, s.mm, s.ss || 0, 0);
const winEnd = new Date(start.getFullYear(), start.getMonth(), start.getDate(), e2.hh, e2.mm, e2.ss || 0, 999);
return { winStart, winEnd };
});
// Keep backend slots that fall inside windows
const existingInWindow = (av.slots || []).filter((s: any) => {
try {
const sd = new Date(s.datetime);
const slotMinutes = sd.getHours() * 60 + sd.getMinutes();
return windows.some((w: any) => {
const ws = w.winStart;
const we = w.winEnd;
const winStartMinutes = ws.getHours() * 60 + ws.getMinutes();
const winEndMinutes = we.getHours() * 60 + we.getMinutes();
return slotMinutes >= winStartMinutes && slotMinutes <= winEndMinutes;
});
} catch (e) { return false; }
});
// Determine step (minutes) from returned slots, fallback to 30
let stepMinutes = 30;
try {
const times = (av.slots || []).map((s: any) => new Date(s.datetime).getTime()).sort((a: number, b: number) => a - b);
const diffs: number[] = [];
for (let i = 1; i < times.length; i++) {
const d = Math.round((times[i] - times[i - 1]) / 60000);
if (d > 0) diffs.push(d);
}
if (diffs.length) {
stepMinutes = Math.min(...diffs);
}
} catch (e) {
// keep fallback
}
// Generate slots from windows using stepMinutes, then merge with existingInWindow
const generatedSet = new Set<string>();
windows.forEach((w: any) => {
try {
// Start at window start rounded to nearest step alignment
const startMs = w.winStart.getTime();
const endMs = w.winEnd.getTime();
// We'll generate by advancing stepMinutes
let cursor = new Date(startMs);
while (cursor.getTime() <= endMs) {
generatedSet.add(cursor.toISOString());
cursor = new Date(cursor.getTime() + stepMinutes * 60000);
}
} catch (e) {
// skip malformed window
}
});
// Merge existingInWindow (prefer backend objects) with generated ones
const mergedMap = new Map<string, { datetime: string; available: boolean }>();
(existingInWindow || []).forEach((s: any) => mergedMap.set(s.datetime, s));
Array.from(generatedSet).forEach((dt) => {
if (!mergedMap.has(dt)) mergedMap.set(dt, { datetime: dt, available: true });
});
const merged = Array.from(mergedMap.values()).sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
console.debug('[CalendarRegistrationForm] slots after merge/generated count', merged.length, 'stepMinutes', stepMinutes);
setAvailableSlots(merged || []);
} else {
// No disponibilidade entries for this weekday -> use av.slots as-is
setAvailableSlots(av.slots || []);
}
} catch (e) {
console.warn('[CalendarRegistrationForm] erro ao filtrar por disponibilidades públicas', e);
setAvailableSlots(av.slots || []);
}
} catch (e) {
console.warn('[CalendarRegistrationForm] falha ao carregar horários disponíveis', e);
if (!mounted) return;
setAvailableSlots([]);
} finally {
if (!mounted) return;
setLoadingSlots(false);
}
})();
return () => { mounted = false; };
}, [(formData as any).doctorId, (formData as any).doctor_id, (formData as any).appointmentDate, (formData as any).appointmentType]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => { const handleChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = event.target; const { name, value } = event.target;
@ -152,6 +450,15 @@ export function CalendarRegistrationForm({ formData, onFormChange }: CalendarReg
return; return;
} }
// if selecting a patientId from the select, also populate patientName
if (name === 'patientId') {
// event.target is a select; get the selected option text
const sel = event.target as HTMLSelectElement;
const selectedText = sel.options[sel.selectedIndex]?.text ?? '';
onFormChange({ ...formData, patientId: value || null, patientName: selectedText });
return;
}
// ensure duration is stored as a number // ensure duration is stored as a number
if (name === 'duration_minutes') { if (name === 'duration_minutes') {
const n = Number(value); const n = Number(value);
@ -199,23 +506,55 @@ export function CalendarRegistrationForm({ formData, onFormChange }: CalendarReg
} }
}, [(formData as any).startTime, (formData as any).duration_minutes, (formData as any).appointmentDate]); }, [(formData as any).startTime, (formData as any).duration_minutes, (formData as any).appointmentDate]);
// derive the doctor options to show based on selected patient (inverse lookup)
const patientSelected = (formData as any).patientId || (formData as any).patient_id;
let effectiveDoctorOptions = doctorOptions || [];
if (patientSelected) {
if (filteredDoctorOptions && filteredDoctorOptions.length) effectiveDoctorOptions = filteredDoctorOptions;
else effectiveDoctorOptions = doctorOptions || [];
}
return ( return (
<form className="space-y-8"> <form className="space-y-8">
<div className="border border-border rounded-md p-6 space-y-4 bg-card"> <div className="border border-border rounded-md p-6 space-y-4 bg-card">
<h2 className="font-medium text-foreground">Informações do paciente</h2> <h2 className="font-medium text-foreground">Informações do paciente</h2>
<div className="grid grid-cols-1 md:grid-cols-12 gap-4"> <div className="grid grid-cols-1 md:grid-cols-12 gap-4">
<div className="md:col-span-6 space-y-2"> <div className="md:col-span-6 space-y-2">
<Label className="text-[13px]">Nome</Label> <Label className="text-[13px]">Nome</Label>
<div className="relative"> <div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input {createMode ? (
name="patientName" <Select
placeholder="Nome do paciente" value={(formData as any).patientId || (formData as any).patient_id || ''}
className="h-11 pl-8 rounded-md transition-colors bg-muted/10" onValueChange={(value) => {
value={formData.patientName || ''} const val = value || null;
disabled const selected = (patientOptions || []).find((p) => p.id === val) || null;
/> onFormChange({ ...formData, patientId: val, patientName: selected ? (selected.full_name || selected.id) : '' });
}}
>
<SelectTrigger className="h-11 w-full rounded-md pl-8 text-[13px]">
<SelectValue placeholder="Selecione um paciente" />
</SelectTrigger>
<SelectContent>
{loadingPatients || loadingPatientsForDoctor ? (
<SelectItem value="__loading_patients__" disabled>Carregando pacientes...</SelectItem>
) : (
(patientOptions || []).map((p) => (
<SelectItem key={p.id} value={p.id}>{(p.full_name || p.nome || p.id) + (p.cpf ? ` - CPF: ${p.cpf}` : '')}</SelectItem>
))
)}
</SelectContent>
</Select>
) : (
<Input
name="patientName"
placeholder="Nome do paciente"
className="h-11 pl-8 rounded-md transition-colors bg-muted/10"
value={formData.patientName || ''}
disabled
/>
)}
</div> </div>
</div> </div>
<div className="md:col-span-6 flex items-start justify-end"> <div className="md:col-span-6 flex items-start justify-end">
@ -246,13 +585,37 @@ export function CalendarRegistrationForm({ formData, onFormChange }: CalendarReg
<h2 className="font-medium text-foreground">Informações do atendimento</h2> <h2 className="font-medium text-foreground">Informações do atendimento</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-[13px]">Nome do profissional *</Label> <Label className="text-[13px]">Nome do profissional *</Label>
<div className="relative"> <div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input name="professionalName" className="h-11 w-full rounded-md pl-8 pr-12 text-[13px] transition-colors hover:bg-muted/30" value={formData.professionalName || ''} onChange={handleChange} disabled /> {createMode ? (
</div> <Select
</div> value={(formData as any).doctorId || (formData as any).doctor_id || ''}
onValueChange={(value) => {
// synthesize a change event compatible with existing handler
const fake = { target: { name: 'doctorId', value } } as unknown as React.ChangeEvent<HTMLSelectElement>;
handleChange(fake);
}}
>
<SelectTrigger className="h-11 w-full rounded-md pl-8 text-[13px]">
<SelectValue placeholder="Selecione um médico" />
</SelectTrigger>
<SelectContent>
{loadingAssignedDoctors ? (
<SelectItem value="__loading_doctors__" disabled>Carregando médicos atribuídos...</SelectItem>
) : (
(effectiveDoctorOptions || []).map((d) => (
<SelectItem key={d.id} value={d.id}>{d.full_name || d.name || d.id}</SelectItem>
))
)}
</SelectContent>
</Select>
) : (
<Input name="professionalName" className="h-11 w-full rounded-md pl-8 pr-12 text-[13px] transition-colors hover:bg-muted/30" value={formData.professionalName || ''} disabled />
)}
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-[13px]">Data *</Label> <Label className="text-[13px]">Data *</Label>
<div className="relative"> <div className="relative">
@ -263,7 +626,69 @@ export function CalendarRegistrationForm({ formData, onFormChange }: CalendarReg
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-[13px]">Início *</Label> <Label className="text-[13px]">Início *</Label>
<Input name="startTime" type="time" className="h-11 w-full rounded-md px-3 text-[13px] transition-colors hover:bg-muted/30" value={formData.startTime || ''} onChange={handleChange} /> {createMode ? (
<Select
value={((): string => {
// try to find a matching slot ISO for the current formData appointmentDate + startTime
try {
const date = (formData as any).appointmentDate || '';
const time = (formData as any).startTime || '';
if (!date || !time) return '';
const match = (availableSlots || []).find((s) => {
try {
const d = new Date(s.datetime);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const dateOnly = d.toISOString().split('T')[0];
return dateOnly === date && `${hh}:${mm}` === time;
} catch (e) {
return false;
}
});
return match ? match.datetime : '';
} catch (e) { return ''; }
})()
}
onValueChange={(value) => {
// value is the slot ISO datetime
try {
const dt = new Date(value);
if (isNaN(dt.getTime())) {
// clear
onFormChange({ ...formData, appointmentDate: (formData as any).appointmentDate || null, startTime: '' });
return;
}
const hh = String(dt.getHours()).padStart(2, '0');
const mm = String(dt.getMinutes()).padStart(2, '0');
const dateOnly = dt.toISOString().split('T')[0];
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}` });
} catch (e) {
// noop
}
}}
>
<SelectTrigger className="h-11 w-full rounded-md pl-3 text-[13px]">
<SelectValue placeholder="--:--" />
</SelectTrigger>
<SelectContent>
{loadingSlots ? (
<SelectItem value="__loading_slots__" disabled>Carregando horários...</SelectItem>
) : (availableSlots && availableSlots.length ? (
(availableSlots || []).map((s) => {
const d = new Date(s.datetime);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const label = `${hh}:${mm}`;
return <SelectItem key={s.datetime} value={s.datetime}>{label}</SelectItem>;
})
) : (
<SelectItem value="__no_slots__" disabled>Nenhum horário disponível</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input name="startTime" type="time" className="h-11 w-full rounded-md px-3 text-[13px] transition-colors hover:bg-muted/30" value={formData.startTime || ''} onChange={handleChange} />
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-[13px]">Término *</Label> <Label className="text-[13px]">Término *</Label>
@ -271,6 +696,41 @@ export function CalendarRegistrationForm({ formData, onFormChange }: CalendarReg
</div> </div>
{/* Profissional solicitante removed per user request */} {/* Profissional solicitante removed per user request */}
</div> </div>
{/* Available slots area (createMode only) */}
{createMode && (
<div className="mt-3">
<Label className="text-[13px]">Horários disponíveis</Label>
<div className="mt-2 grid grid-cols-3 gap-2">
{loadingSlots ? (
<div className="col-span-3">Carregando horários...</div>
) : availableSlots && availableSlots.length ? (
availableSlots.map((s) => {
const dt = new Date(s.datetime);
const hh = String(dt.getHours()).padStart(2, '0');
const mm = String(dt.getMinutes()).padStart(2, '0');
const label = `${hh}:${mm}`;
return (
<button
key={s.datetime}
type="button"
className={`h-10 rounded-md border ${formData.startTime === `${hh}:${mm}` ? 'bg-blue-600 text-white' : 'bg-background'}`}
onClick={() => {
// when selecting a slot, set appointmentDate (if missing) and startTime
const isoDate = dt.toISOString();
const dateOnly = isoDate.split('T')[0];
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}` });
}}
>
{label}
</button>
);
})
) : (
<div className="col-span-3 text-sm text-muted-foreground">Nenhum horário disponível para o médico nesta data.</div>
)}
</div>
</div>
)}
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">

View File

@ -983,6 +983,126 @@ export type Appointment = {
updated_by?: string | null; updated_by?: string | null;
}; };
// Payload to create an appointment
export type AppointmentCreate = {
patient_id: string;
doctor_id: string;
scheduled_at: string; // ISO date-time
duration_minutes?: number;
appointment_type?: 'presencial' | 'telemedicina' | string;
chief_complaint?: string | null;
patient_notes?: string | null;
insurance_provider?: string | null;
};
/**
* Chama a Function `/functions/v1/get-available-slots` para obter os slots disponíveis de um médico
*/
export async function getAvailableSlots(input: { doctor_id: string; start_date: string; end_date: string; appointment_type?: string }): Promise<{ slots: Array<{ datetime: string; available: boolean }> }> {
if (!input || !input.doctor_id || !input.start_date || !input.end_date) {
throw new Error('Parâmetros inválidos. É necessário doctor_id, start_date e end_date.');
}
const url = `${API_BASE}/functions/v1/get-available-slots`;
try {
const res = await fetch(url, {
method: 'POST',
headers: { ...baseHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ doctor_id: input.doctor_id, start_date: input.start_date, end_date: input.end_date, appointment_type: input.appointment_type ?? 'presencial' }),
});
// Do not short-circuit; let parse() produce friendly errors
const parsed = await parse<{ slots: Array<{ datetime: string; available: boolean }> }>(res);
// Ensure consistent return shape
if (!parsed || !Array.isArray((parsed as any).slots)) return { slots: [] };
return parsed as { slots: Array<{ datetime: string; available: boolean }> };
} catch (err) {
console.error('[getAvailableSlots] erro ao buscar horários disponíveis', err);
throw err;
}
}
/**
* Cria um agendamento (POST /rest/v1/appointments) verificando disponibilidade previamente
*/
export async function criarAgendamento(input: AppointmentCreate): Promise<Appointment> {
if (!input || !input.patient_id || !input.doctor_id || !input.scheduled_at) {
throw new Error('Parâmetros inválidos para criar agendamento. patient_id, doctor_id e scheduled_at são obrigatórios.');
}
// Normalize scheduled_at to ISO
const scheduledDate = new Date(input.scheduled_at);
if (isNaN(scheduledDate.getTime())) throw new Error('scheduled_at inválido');
// Build day range for availability check (start of day to end of day of scheduled date)
const startDay = new Date(scheduledDate);
startDay.setHours(0, 0, 0, 0);
const endDay = new Date(scheduledDate);
endDay.setHours(23, 59, 59, 999);
// Query availability
const av = await getAvailableSlots({ doctor_id: input.doctor_id, start_date: startDay.toISOString(), end_date: endDay.toISOString(), appointment_type: input.appointment_type });
const scheduledMs = scheduledDate.getTime();
const matching = (av.slots || []).find((s) => {
try {
const dt = new Date(s.datetime).getTime();
// allow small tolerance (<= 60s) to account for formatting/timezone differences
return s.available && Math.abs(dt - scheduledMs) <= 60_000;
} catch (e) {
return false;
}
});
if (!matching) {
throw new Error('Horário não disponível para o médico no horário solicitado. Verifique a disponibilidade antes de agendar.');
}
// Determine created_by similar to other creators (prefer localStorage then user-info)
let createdBy: string | null = null;
if (typeof window !== 'undefined') {
try {
const raw = localStorage.getItem(AUTH_STORAGE_KEYS.USER);
if (raw) {
const parsed = JSON.parse(raw);
createdBy = parsed?.id ?? parsed?.user?.id ?? null;
}
} catch (e) {
// ignore
}
}
if (!createdBy) {
try {
const info = await getUserInfo();
createdBy = info?.user?.id ?? null;
} catch (e) {
// ignore
}
}
const payload: any = {
patient_id: input.patient_id,
doctor_id: input.doctor_id,
scheduled_at: new Date(scheduledDate).toISOString(),
duration_minutes: input.duration_minutes ?? 30,
appointment_type: input.appointment_type ?? 'presencial',
chief_complaint: input.chief_complaint ?? null,
patient_notes: input.patient_notes ?? null,
insurance_provider: input.insurance_provider ?? null,
};
if (createdBy) payload.created_by = createdBy;
const url = `${REST}/appointments`;
const res = await fetch(url, {
method: 'POST',
headers: withPrefer({ ...baseHeaders(), 'Content-Type': 'application/json' }, 'return=representation'),
body: JSON.stringify(payload),
});
const created = await parse<Appointment>(res);
return created;
}
// Payload for updating an appointment (PATCH /rest/v1/appointments/{id}) // Payload for updating an appointment (PATCH /rest/v1/appointments/{id})
export type AppointmentUpdate = Partial<{ export type AppointmentUpdate = Partial<{
scheduled_at: string; scheduled_at: string;
@ -1159,6 +1279,36 @@ export async function buscarPacientesPorIds(ids: Array<string | number>): Promis
return unique; return unique;
} }
/**
* Busca pacientes atribuídos a um médico (usando o user_id do médico para consultar patient_assignments)
* - Primeiro busca o médico para obter o campo user_id
* - Consulta a tabela patient_assignments para obter patient_id vinculados ao user_id
* - Retorna os pacientes via buscarPacientesPorIds
*/
export async function buscarPacientesPorMedico(doctorId: string): Promise<Paciente[]> {
if (!doctorId) return [];
try {
// buscar médico para obter user_id
const medico = await buscarMedicoPorId(doctorId).catch(() => null);
const userId = medico?.user_id ?? medico?.created_by ?? null;
if (!userId) {
// se não houver user_id, não há uma forma confiável de mapear atribuições
return [];
}
// buscar atribuições para esse user_id
const url = `${REST}/patient_assignments?user_id=eq.${encodeURIComponent(String(userId))}&select=patient_id`;
const res = await fetch(url, { method: 'GET', headers: baseHeaders() });
const rows = await parse<Array<{ patient_id?: string }>>(res).catch(() => []);
const ids = (rows || []).map((r) => r.patient_id).filter(Boolean) as string[];
if (!ids.length) return [];
return await buscarPacientesPorIds(ids);
} catch (err) {
console.warn('[buscarPacientesPorMedico] falha ao obter pacientes do médico', doctorId, err);
return [];
}
}
export async function criarPaciente(input: PacienteInput): Promise<Paciente> { export async function criarPaciente(input: PacienteInput): Promise<Paciente> {
const url = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/patients`; const url = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/patients`;
const res = await fetch(url, { const res = await fetch(url, {