develop #83
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user