develop #83

Merged
M-Gabrielly merged 426 commits from develop into main 2025-12-04 04:13:15 +00:00
2 changed files with 154 additions and 5 deletions
Showing only changes of commit 0c8bc4534a - Show all commits

View File

@ -1,6 +1,8 @@
"use client" "use client"
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { useSearchParams, useRouter } from 'next/navigation' import { useSearchParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
@ -28,6 +30,7 @@ import {
buscarMedicos, buscarMedicos,
getAvailableSlots, getAvailableSlots,
criarAgendamento, criarAgendamento,
criarAgendamentoDireto,
getUserInfo, getUserInfo,
buscarPacientes, buscarPacientes,
listarDisponibilidades, listarDisponibilidades,
@ -81,6 +84,16 @@ export default function ResultadosClient() {
const [medicoSelecionado, setMedicoSelecionado] = useState<Medico | null>(null) const [medicoSelecionado, setMedicoSelecionado] = useState<Medico | null>(null)
const [abaDetalhe, setAbaDetalhe] = useState('experiencia') const [abaDetalhe, setAbaDetalhe] = useState('experiencia')
// Confirmation dialog for booking: hold pending selection until user confirms
const [confirmOpen, setConfirmOpen] = useState(false)
const [pendingAppointment, setPendingAppointment] = useState<{ doctorId: string; iso: string } | null>(null)
const [confirmLoading, setConfirmLoading] = useState(false)
// Fields editable in the confirmation dialog to be sent to the create endpoint
const [confirmDuration, setConfirmDuration] = useState<number>(30)
const [confirmInsurance, setConfirmInsurance] = useState<string>('')
const [confirmChiefComplaint, setConfirmChiefComplaint] = useState<string>('')
const [confirmPatientNotes, setConfirmPatientNotes] = useState<string>('')
// Toast simples // Toast simples
const [toast, setToast] = useState<{ type: 'success' | 'error', msg: string } | null>(null) const [toast, setToast] = useState<{ type: 'success' | 'error', msg: string } | null>(null)
const showToast = (type: 'success' | 'error', msg: string) => { const showToast = (type: 'success' | 'error', msg: string) => {
@ -193,7 +206,7 @@ export default function ResultadosClient() {
} }
} }
// 4) Agendar ao clicar em um horário // 4) Agendar ao clicar em um horário (performs the actual create call)
async function agendar(doctorId: string, iso: string) { async function agendar(doctorId: string, iso: string) {
if (!patientId) { if (!patientId) {
showToast('error', 'Paciente não identificado. Faça login novamente.') showToast('error', 'Paciente não identificado. Faça login novamente.')
@ -220,6 +233,55 @@ export default function ResultadosClient() {
} }
} }
// Open confirmation dialog for a selected slot instead of immediately booking
function openConfirmDialog(doctorId: string, iso: string) {
setPendingAppointment({ doctorId, iso })
setConfirmOpen(true)
}
// Called when the user confirms the booking in the dialog
async function confirmAndBook() {
if (!pendingAppointment) return
const { doctorId, iso } = pendingAppointment
if (!patientId) {
showToast('error', 'Paciente não identificado. Faça login novamente.')
return
}
// Debug: indicate the handler was invoked
console.debug('[ResultadosClient] confirmAndBook invoked', { doctorId, iso, patientId, confirmDuration, confirmInsurance })
showToast('success', 'Iniciando agendamento...')
setConfirmLoading(true)
try {
// Use direct POST to ensure creation even if availability checks would block
await criarAgendamentoDireto({
patient_id: String(patientId),
doctor_id: String(doctorId),
scheduled_at: String(iso),
duration_minutes: Number(confirmDuration) || 30,
appointment_type: (tipoConsulta === 'local' ? 'presencial' : 'telemedicina'),
chief_complaint: confirmChiefComplaint || null,
patient_notes: confirmPatientNotes || null,
insurance_provider: confirmInsurance || null,
})
showToast('success', 'Consulta agendada com sucesso!')
// remover horário da lista local
setAgendaByDoctor((prev) => {
const days = prev[doctorId]
if (!days) return prev
const updated = days.map(d => ({ ...d, horarios: d.horarios.filter(h => h.iso !== iso) }))
return { ...prev, [doctorId]: updated }
})
setConfirmOpen(false)
setPendingAppointment(null)
// Navigate to agenda after a short delay so user sees the toast
setTimeout(() => router.push('/agenda'), 500)
} catch (e: any) {
showToast('error', e?.message || 'Falha ao agendar')
} finally {
setConfirmLoading(false)
}
}
// Fetch slots for an arbitrary date using the same logic as CalendarRegistrationForm // Fetch slots for an arbitrary date using the same logic as CalendarRegistrationForm
async function fetchSlotsForDate(doctorId: string, dateOnly: string) { async function fetchSlotsForDate(doctorId: string, dateOnly: string) {
if (!doctorId || !dateOnly) return [] if (!doctorId || !dateOnly) return []
@ -439,6 +501,39 @@ export default function ResultadosClient() {
</div> </div>
)} )}
{/* Confirmation dialog shown when a user selects a slot */}
<Dialog open={confirmOpen} onOpenChange={(open) => { if (!open) { setConfirmOpen(false); setPendingAppointment(null); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirmar agendamento</DialogTitle>
</DialogHeader>
<div className="mt-2">
{pendingAppointment ? (
(() => {
const doc = medicos.find(m => String(m.id) === String(pendingAppointment.doctorId))
const doctorName = doc ? (doc.full_name || (doc as any).name || 'Profissional') : 'Profissional'
const when = (() => {
try { return new Date(pendingAppointment.iso).toLocaleString('pt-BR', { dateStyle: 'long', timeStyle: 'short' }) } catch { return pendingAppointment.iso }
})()
return (
<div className="space-y-2">
<p>Profissional: <strong>{doctorName}</strong></p>
<p>Data / Hora: <strong>{when}</strong></p>
<p>Paciente: <strong>Você</strong></p>
</div>
)
})()
) : (
<p>Carregando informações...</p>
)}
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="ghost" onClick={() => { setConfirmOpen(false); setPendingAppointment(null); }}>Cancelar</Button>
<Button onClick={confirmAndBook} disabled={confirmLoading}>{confirmLoading ? 'Agendando...' : 'Marcar consulta'}</Button>
</div>
</DialogContent>
</Dialog>
{/* Hero de filtros (mantido) */} {/* Hero de filtros (mantido) */}
<section className="rounded-3xl bg-primary p-6 text-primary-foreground shadow-lg"> <section className="rounded-3xl bg-primary p-6 text-primary-foreground shadow-lg">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
@ -644,7 +739,7 @@ export default function ResultadosClient() {
{nearestSlotByDoctor[id] && ( {nearestSlotByDoctor[id] && (
<div className="mb-2 flex items-center gap-3"> <div className="mb-2 flex items-center gap-3">
<span className="text-sm text-muted-foreground">Próximo horário:</span> <span className="text-sm text-muted-foreground">Próximo horário:</span>
<Button className="h-9 rounded-full bg-primary/10 text-primary" onClick={() => agendar(id, nearestSlotByDoctor[id]!.iso)}> <Button className="h-9 rounded-full bg-primary/10 text-primary" onClick={() => openConfirmDialog(id, nearestSlotByDoctor[id]!.iso)}>
{nearestSlotByDoctor[id]!.label} {nearestSlotByDoctor[id]!.label}
</Button> </Button>
</div> </div>
@ -706,7 +801,7 @@ export default function ResultadosClient() {
key={h.iso} key={h.iso}
type="button" type="button"
className="rounded-lg bg-primary/10 px-2 py-1 text-xs font-medium text-primary transition hover:bg-primary hover:text-primary-foreground" className="rounded-lg bg-primary/10 px-2 py-1 text-xs font-medium text-primary transition hover:bg-primary hover:text-primary-foreground"
onClick={() => agendar(id, h.iso)} onClick={() => openConfirmDialog(id, h.iso)}
> >
{h.label} {h.label}
</button> </button>
@ -827,7 +922,7 @@ export default function ResultadosClient() {
key={h.iso} key={h.iso}
type="button" type="button"
className="rounded-lg bg-primary/10 px-2 py-1 text-xs font-medium text-primary transition hover:bg-primary hover:text-primary-foreground" className="rounded-lg bg-primary/10 px-2 py-1 text-xs font-medium text-primary transition hover:bg-primary hover:text-primary-foreground"
onClick={() => agendar(String(medicoSelecionado.id), h.iso)} onClick={() => openConfirmDialog(String(medicoSelecionado.id), h.iso)}
> >
{h.label} {h.label}
</button> </button>
@ -873,7 +968,7 @@ export default function ResultadosClient() {
) : (moreTimesSlots.length ? ( ) : (moreTimesSlots.length ? (
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{moreTimesSlots.map(s => ( {moreTimesSlots.map(s => (
<button key={s.iso} type="button" className="rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary hover:bg-primary hover:text-primary-foreground" onClick={() => { if (moreTimesForDoctor) { agendar(moreTimesForDoctor, s.iso); setMoreTimesForDoctor(null); } }}> <button key={s.iso} type="button" className="rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary hover:bg-primary hover:text-primary-foreground" onClick={() => { if (moreTimesForDoctor) { openConfirmDialog(moreTimesForDoctor, s.iso); setMoreTimesForDoctor(null); } }}>
{s.label} {s.label}
</button> </button>
))} ))}

View File

@ -1196,6 +1196,60 @@ export async function criarAgendamento(input: AppointmentCreate): Promise<Appoin
return created; return created;
} }
/**
* Cria um agendamento direto no endpoint REST sem realizar validações locais
* como checagem de disponibilidade ou exceções. Use com cautela.
*/
export async function criarAgendamentoDireto(input: AppointmentCreate & { created_by?: string | null }): 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.');
}
// Determine created_by: prefer explicit, then localStorage, then user-info
let createdBy: string | null = input.created_by ?? null;
if (!createdBy && 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().catch(() => null);
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(input.scheduled_at).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;