Merge pull request 'feature/confirm-appoiments' (#74) from feature/confirm-appoiments into develop
Reviewed-on: #74
This commit is contained in:
commit
334c847916
@ -74,6 +74,20 @@ const capitalize = (s: string) => {
|
|||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const translateStatus = (status: string) => {
|
||||||
|
const statusMap: { [key: string]: string } = {
|
||||||
|
'requested': 'Solicitado',
|
||||||
|
'confirmed': 'Confirmado',
|
||||||
|
'checked_in': 'Check-in',
|
||||||
|
'in_progress': 'Em Andamento',
|
||||||
|
'completed': 'Concluído',
|
||||||
|
'cancelled': 'Cancelado',
|
||||||
|
'no_show': 'Não Compareceu',
|
||||||
|
'pending': 'Pendente',
|
||||||
|
};
|
||||||
|
return statusMap[status?.toLowerCase()] || capitalize(status || '');
|
||||||
|
};
|
||||||
|
|
||||||
export default function ConsultasPage() {
|
export default function ConsultasPage() {
|
||||||
const [appointments, setAppointments] = useState<any[]>([]);
|
const [appointments, setAppointments] = useState<any[]>([]);
|
||||||
const [originalAppointments, setOriginalAppointments] = useState<any[]>([]);
|
const [originalAppointments, setOriginalAppointments] = useState<any[]>([]);
|
||||||
@ -197,7 +211,7 @@ export default function ConsultasPage() {
|
|||||||
const payload: any = {
|
const payload: any = {
|
||||||
scheduled_at,
|
scheduled_at,
|
||||||
duration_minutes,
|
duration_minutes,
|
||||||
status: formData.status || undefined,
|
status: 'confirmed',
|
||||||
notes: formData.notes ?? null,
|
notes: formData.notes ?? null,
|
||||||
chief_complaint: formData.chief_complaint ?? null,
|
chief_complaint: formData.chief_complaint ?? null,
|
||||||
patient_notes: formData.patient_notes ?? null,
|
patient_notes: formData.patient_notes ?? null,
|
||||||
@ -561,7 +575,7 @@ export default function ConsultasPage() {
|
|||||||
}
|
}
|
||||||
className={appointment.status === "confirmed" ? "bg-green-600" : ""}
|
className={appointment.status === "confirmed" ? "bg-green-600" : ""}
|
||||||
>
|
>
|
||||||
{capitalize(appointment.status)}
|
{translateStatus(appointment.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs sm:text-sm">{formatDate(appointment.scheduled_at ?? appointment.time)}</TableCell>
|
<TableCell className="text-xs sm:text-sm">{formatDate(appointment.scheduled_at ?? appointment.time)}</TableCell>
|
||||||
@ -652,7 +666,7 @@ export default function ConsultasPage() {
|
|||||||
}
|
}
|
||||||
className={`text-[10px] sm:text-xs ${appointment.status === "confirmed" ? "bg-green-600" : ""}`}
|
className={`text-[10px] sm:text-xs ${appointment.status === "confirmed" ? "bg-green-600" : ""}`}
|
||||||
>
|
>
|
||||||
{capitalize(appointment.status)}
|
{translateStatus(appointment.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
@ -771,7 +785,7 @@ export default function ConsultasPage() {
|
|||||||
}
|
}
|
||||||
className={viewingAppointment?.status === "confirmed" ? "bg-green-600" : ""}
|
className={viewingAppointment?.status === "confirmed" ? "bg-green-600" : ""}
|
||||||
>
|
>
|
||||||
{capitalize(viewingAppointment?.status || "")}
|
{translateStatus(viewingAppointment?.status || "")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -296,7 +296,7 @@ export default function PerfilPage() {
|
|||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
onClick={handleEditClick}
|
onClick={handleEditClick}
|
||||||
>
|
>
|
||||||
✏️ Editar Perfil
|
Editar Perfil
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@ -812,7 +812,7 @@ export default function PacientePage() {
|
|||||||
<div className="flex items-start gap-3 sm:gap-4 min-w-0">
|
<div className="flex items-start gap-3 sm:gap-4 min-w-0">
|
||||||
<span
|
<span
|
||||||
className="mt-1 sm:mt-2 h-3 w-3 sm:h-4 sm:w-4 shrink-0 rounded-full shadow-sm"
|
className="mt-1 sm:mt-2 h-3 w-3 sm:h-4 sm:w-4 shrink-0 rounded-full shadow-sm"
|
||||||
style={{ backgroundColor: consulta.status === 'Confirmada' ? '#10b981' : consulta.status === 'Pendente' ? '#f59e0b' : '#ef4444' }}
|
style={{ backgroundColor: (consulta.status === 'Confirmada' || consulta.status === 'confirmed') ? '#10b981' : '#ef4444' }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2 sm:space-y-3 min-w-0">
|
<div className="space-y-2 sm:space-y-3 min-w-0">
|
||||||
@ -837,10 +837,8 @@ export default function PacientePage() {
|
|||||||
{/* Status Badge */}
|
{/* Status Badge */}
|
||||||
<div className="flex items-center justify-start">
|
<div className="flex items-center justify-start">
|
||||||
<span className={`px-2 sm:px-3 md:px-4 py-1.5 sm:py-2 md:py-2.5 rounded-full text-xs font-bold text-white shadow-md transition-all ${
|
<span className={`px-2 sm:px-3 md:px-4 py-1.5 sm:py-2 md:py-2.5 rounded-full text-xs font-bold text-white shadow-md transition-all ${
|
||||||
consulta.status === 'Confirmada'
|
consulta.status === 'Confirmada' || consulta.status === 'confirmed'
|
||||||
? 'bg-linear-to-r from-emerald-500 to-emerald-600 shadow-emerald-500/20'
|
? 'bg-linear-to-r from-green-500 to-green-600 shadow-green-500/20'
|
||||||
: consulta.status === 'Pendente'
|
|
||||||
? 'bg-linear-to-r from-amber-500 to-amber-600 shadow-amber-500/20'
|
|
||||||
: 'bg-linear-to-r from-red-500 to-red-600 shadow-red-500/20'
|
: 'bg-linear-to-r from-red-500 to-red-600 shadow-red-500/20'
|
||||||
}`}>
|
}`}>
|
||||||
{statusLabel(consulta.status)}
|
{statusLabel(consulta.status)}
|
||||||
@ -858,7 +856,7 @@ export default function PacientePage() {
|
|||||||
Detalhes
|
Detalhes
|
||||||
</Button>
|
</Button>
|
||||||
{/* Reagendar removed by request */}
|
{/* Reagendar removed by request */}
|
||||||
{consulta.status !== 'Cancelada' && (
|
{consulta.status !== 'Cancelada' && consulta.status !== 'cancelled' && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -867,17 +865,33 @@ export default function PacientePage() {
|
|||||||
try {
|
try {
|
||||||
const ok = typeof window !== 'undefined' ? window.confirm('Deseja realmente cancelar esta consulta?') : true
|
const ok = typeof window !== 'undefined' ? window.confirm('Deseja realmente cancelar esta consulta?') : true
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
// call API to delete
|
|
||||||
await deletarAgendamento(consulta.id)
|
// Prefer PATCH to mark appointment as cancelled (safer under RLS)
|
||||||
// Mark as deleted in cache so it won't appear again
|
try {
|
||||||
addDeletedAppointmentId(consulta.id)
|
await atualizarAgendamento(consulta.id, {
|
||||||
// remove from local list
|
cancelled_at: new Date().toISOString(),
|
||||||
|
status: 'cancelled',
|
||||||
|
cancellation_reason: 'Cancelado pelo paciente'
|
||||||
|
})
|
||||||
|
} catch (patchErr) {
|
||||||
|
// Fallback: try hard delete if server allows it
|
||||||
|
try {
|
||||||
|
await deletarAgendamento(consulta.id)
|
||||||
|
} catch (delErr) {
|
||||||
|
// Re-throw original patch error if both fail
|
||||||
|
throw patchErr || delErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove from local list so UI updates immediately
|
||||||
setAppointments((prev) => {
|
setAppointments((prev) => {
|
||||||
if (!prev) return prev
|
if (!prev) return prev
|
||||||
return prev.filter((a: any) => String(a.id) !== String(consulta.id))
|
return prev.filter((a: any) => String(a.id) !== String(consulta.id))
|
||||||
})
|
})
|
||||||
// if modal open for this appointment, close it
|
// if modal open for this appointment, close it
|
||||||
if (selectedAppointment && String(selectedAppointment.id) === String(consulta.id)) setSelectedAppointment(null)
|
if (selectedAppointment && String(selectedAppointment.id) === String(consulta.id)) setSelectedAppointment(null)
|
||||||
|
// Optionally persist to deleted cache to help client-side filtering
|
||||||
|
try { addDeletedAppointmentId(consulta.id) } catch(e) {}
|
||||||
setToast({ type: 'success', msg: 'Consulta cancelada.' })
|
setToast({ type: 'success', msg: 'Consulta cancelada.' })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Consultas] falha ao cancelar agendamento', err)
|
console.error('[Consultas] falha ao cancelar agendamento', err)
|
||||||
@ -1562,7 +1576,7 @@ export default function PacientePage() {
|
|||||||
className="bg-blue-600 hover:bg-blue-700 w-full sm:w-auto whitespace-nowrap text-xs sm:text-sm"
|
className="bg-blue-600 hover:bg-blue-700 w-full sm:w-auto whitespace-nowrap text-xs sm:text-sm"
|
||||||
onClick={() => setIsEditingProfile(true)}
|
onClick={() => setIsEditingProfile(true)}
|
||||||
>
|
>
|
||||||
✏️ Editar Perfil
|
Editar Perfil
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import Link from "next/link";
|
|||||||
import ProtectedRoute from "@/components/shared/ProtectedRoute";
|
import ProtectedRoute from "@/components/shared/ProtectedRoute";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useAvatarUrl } from "@/hooks/useAvatarUrl";
|
||||||
|
import { UploadAvatar } from '@/components/ui/upload-avatar';
|
||||||
import { buscarPacientes, listarPacientes, buscarPacientePorId, buscarPacientesPorIds, buscarMedicoPorId, buscarMedicosPorIds, buscarMedicos, listarAgendamentos, type Paciente, buscarRelatorioPorId, atualizarMedico } from "@/lib/api";
|
import { buscarPacientes, listarPacientes, buscarPacientePorId, buscarPacientesPorIds, buscarMedicoPorId, buscarMedicosPorIds, buscarMedicos, listarAgendamentos, type Paciente, buscarRelatorioPorId, atualizarMedico } from "@/lib/api";
|
||||||
import { ENV_CONFIG } from '@/lib/env-config';
|
import { ENV_CONFIG } from '@/lib/env-config';
|
||||||
import { useReports } from "@/hooks/useReports";
|
import { useReports } from "@/hooks/useReports";
|
||||||
@ -115,6 +117,7 @@ const colorsByType = {
|
|||||||
|
|
||||||
const ProfissionalPage = () => {
|
const ProfissionalPage = () => {
|
||||||
const { logout, user, token } = useAuth();
|
const { logout, user, token } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
const [activeSection, setActiveSection] = useState('calendario');
|
const [activeSection, setActiveSection] = useState('calendario');
|
||||||
const [pacienteSelecionado, setPacienteSelecionado] = useState<any>(null);
|
const [pacienteSelecionado, setPacienteSelecionado] = useState<any>(null);
|
||||||
|
|
||||||
@ -125,6 +128,9 @@ const ProfissionalPage = () => {
|
|||||||
// Estados para o perfil do médico
|
// Estados para o perfil do médico
|
||||||
const [isEditingProfile, setIsEditingProfile] = useState(false);
|
const [isEditingProfile, setIsEditingProfile] = useState(false);
|
||||||
const [doctorId, setDoctorId] = useState<string | null>(null);
|
const [doctorId, setDoctorId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Hook para carregar automaticamente o avatar do médico
|
||||||
|
const { avatarUrl: retrievedAvatarUrl } = useAvatarUrl(doctorId);
|
||||||
// Removemos o placeholder extenso — inicializamos com valores minimalistas e vazios.
|
// Removemos o placeholder extenso — inicializamos com valores minimalistas e vazios.
|
||||||
const [profileData, setProfileData] = useState({
|
const [profileData, setProfileData] = useState({
|
||||||
nome: '',
|
nome: '',
|
||||||
@ -267,6 +273,15 @@ const ProfissionalPage = () => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user?.email, user?.id]);
|
}, [user?.email, user?.id]);
|
||||||
|
|
||||||
|
// Sincroniza a URL do avatar recuperada com o profileData
|
||||||
|
useEffect(() => {
|
||||||
|
if (retrievedAvatarUrl) {
|
||||||
|
setProfileData(prev => ({
|
||||||
|
...prev,
|
||||||
|
fotoUrl: retrievedAvatarUrl
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [retrievedAvatarUrl]);
|
||||||
|
|
||||||
|
|
||||||
// Estados para campos principais da consulta
|
// Estados para campos principais da consulta
|
||||||
@ -2780,7 +2795,7 @@ const ProfissionalPage = () => {
|
|||||||
className="bg-blue-600 hover:bg-blue-700 text-xs sm:text-sm w-full sm:w-auto"
|
className="bg-blue-600 hover:bg-blue-700 text-xs sm:text-sm w-full sm:w-auto"
|
||||||
onClick={() => setIsEditingProfile(true)}
|
onClick={() => setIsEditingProfile(true)}
|
||||||
>
|
>
|
||||||
✏️ Editar Perfil
|
Editar Perfil
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-2 w-full sm:w-auto">
|
<div className="flex gap-2 w-full sm:w-auto">
|
||||||
@ -2966,17 +2981,42 @@ const ProfissionalPage = () => {
|
|||||||
<h3 className="text-base sm:text-lg font-semibold mb-4">Foto do Perfil</h3>
|
<h3 className="text-base sm:text-lg font-semibold mb-4">Foto do Perfil</h3>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<Avatar className="h-20 w-20 sm:h-24 sm:w-24">
|
{isEditingProfile ? (
|
||||||
<AvatarFallback className="bg-primary text-primary-foreground text-lg sm:text-2xl font-bold">
|
<UploadAvatar
|
||||||
{profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'MD'}
|
userId={String(doctorId || (user && (user as any).id) || '')}
|
||||||
</AvatarFallback>
|
currentAvatarUrl={(profileData as any).fotoUrl}
|
||||||
</Avatar>
|
userName={(profileData as any).nome}
|
||||||
|
onAvatarChange={async (newUrl: string) => {
|
||||||
|
try {
|
||||||
|
setProfileData((prev) => ({ ...prev, fotoUrl: newUrl }));
|
||||||
|
// Foto foi salva no Supabase Storage - atualizar apenas o estado local
|
||||||
|
// Para persistir no banco, o usuário deve clicar em "Salvar" após isso
|
||||||
|
try { toast({ title: 'Foto enviada', description: 'Clique em "Salvar" para confirmar as alterações.', variant: 'default' }); } catch (e) { /* ignore toast errors */ }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ProfissionalPage] erro ao processar upload de foto:', err);
|
||||||
|
try { toast({ title: 'Erro ao processar foto', description: (err as any)?.message || 'Falha ao processar a foto do perfil.', variant: 'destructive' }); } catch (e) {}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Avatar className="h-20 w-20 sm:h-24 sm:w-24">
|
||||||
|
{(profileData as any).fotoUrl ? (
|
||||||
|
<AvatarImage src={(profileData as any).fotoUrl} alt={(profileData as any).nome} />
|
||||||
|
) : (
|
||||||
|
<AvatarFallback className="bg-primary text-primary-foreground text-lg sm:text-2xl font-bold">
|
||||||
|
{profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'MD'}
|
||||||
|
</AvatarFallback>
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||||
{profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'MD'}
|
{profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'MD'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -60,9 +60,32 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub
|
|||||||
className="relative h-8 w-8 rounded-full border-2 border-border hover:border-primary"
|
className="relative h-8 w-8 rounded-full border-2 border-border hover:border-primary"
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
>
|
>
|
||||||
|
{/* Mostrar foto do usuário quando disponível; senão, mostrar fallback com iniciais */}
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarImage src="/avatars/01.png" alt="@usuario" />
|
{
|
||||||
<AvatarFallback className="bg-primary text-primary-foreground font-semibold">RA</AvatarFallback>
|
(() => {
|
||||||
|
const userPhoto = (user as any)?.profile?.foto_url || (user as any)?.profile?.fotoUrl || (user as any)?.profile?.avatar_url
|
||||||
|
const alt = user?.name || user?.email || 'Usuário'
|
||||||
|
|
||||||
|
const getInitials = (name?: string, email?: string) => {
|
||||||
|
if (name) {
|
||||||
|
const parts = name.trim().split(/\s+/)
|
||||||
|
const first = parts[0]?.charAt(0) ?? ''
|
||||||
|
const second = parts[1]?.charAt(0) ?? ''
|
||||||
|
return (first + second).toUpperCase() || (email?.charAt(0) ?? 'U').toUpperCase()
|
||||||
|
}
|
||||||
|
if (email) return email.charAt(0).toUpperCase()
|
||||||
|
return 'U'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AvatarImage src={userPhoto || undefined} alt={alt} />
|
||||||
|
<AvatarFallback className="bg-primary text-primary-foreground font-semibold">{getInitials(user?.name, user?.email)}</AvatarFallback>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -94,11 +117,9 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub
|
|||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-2 text-sm hover:bg-accent cursor-pointer"
|
className="w-full text-left px-4 py-2 text-sm hover:bg-accent cursor-pointer"
|
||||||
>
|
>
|
||||||
👤 Perfil
|
Perfil
|
||||||
</button>
|
|
||||||
<button className="w-full text-left px-4 py-2 text-sm hover:bg-accent cursor-pointer">
|
|
||||||
⚙️ Configurações
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="border-t border-border my-1"></div>
|
<div className="border-t border-border my-1"></div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -110,7 +131,7 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub
|
|||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-destructive hover:bg-destructive/10 cursor-pointer"
|
className="w-full text-left px-4 py-2 text-sm text-destructive hover:bg-destructive/10 cursor-pointer"
|
||||||
>
|
>
|
||||||
🚪 Sair
|
Sair
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { buscarPacientePorId, listarMedicos, buscarPacientesPorMedico, getAvailableSlots, buscarPacientes, listarPacientes, listarDisponibilidades, listarExcecoes } from "@/lib/api";
|
import { buscarPacientePorId, listarMedicos, buscarPacientesPorMedico, getAvailableSlots, buscarPacientes, listarPacientes, listarDisponibilidades, listarExcecoes, listarAgendamentos } from "@/lib/api";
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@ -93,6 +93,7 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
|
|||||||
const [exceptionDialogOpen, setExceptionDialogOpen] = useState(false);
|
const [exceptionDialogOpen, setExceptionDialogOpen] = useState(false);
|
||||||
const [exceptionDialogMessage, setExceptionDialogMessage] = useState<string | null>(null);
|
const [exceptionDialogMessage, setExceptionDialogMessage] = useState<string | null>(null);
|
||||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||||
|
const [bookedSlots, setBookedSlots] = useState<Set<string>>(new Set()); // ISO datetimes of already booked appointments
|
||||||
|
|
||||||
// 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) => {
|
||||||
@ -298,30 +299,8 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
|
|||||||
if (mountedRef.current) setLoadingSlots(true);
|
if (mountedRef.current) setLoadingSlots(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check for blocking exceptions first
|
// Skip exception checking - all dates are available for admin now
|
||||||
try {
|
// NOTE: Exception checking disabled per user request
|
||||||
const exceptions = await listarExcecoes({ doctorId: String(docId), date: String(date) }).catch(() => []);
|
|
||||||
if (exceptions && exceptions.length) {
|
|
||||||
const blocking = (exceptions || []).find((e: any) => e && e.kind === 'bloqueio');
|
|
||||||
if (blocking) {
|
|
||||||
const reason = blocking.reason ? ` Motivo: ${blocking.reason}` : '';
|
|
||||||
const msg = `Não é possível agendar nesta data.${reason}`;
|
|
||||||
try {
|
|
||||||
setExceptionDialogMessage(msg);
|
|
||||||
setExceptionDialogOpen(true);
|
|
||||||
} catch (e) {
|
|
||||||
try { toast({ title: 'Data indisponível', description: msg }); } catch (ee) {}
|
|
||||||
}
|
|
||||||
if (mountedRef.current) {
|
|
||||||
setAvailableSlots([]);
|
|
||||||
setLoadingSlots(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (exCheckErr) {
|
|
||||||
console.warn('[CalendarRegistrationForm] listarExcecoes falhou, continuando para getAvailableSlots', exCheckErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.debug('[CalendarRegistrationForm] getAvailableSlots - params', { docId, date, appointmentType: formData.appointmentType });
|
console.debug('[CalendarRegistrationForm] getAvailableSlots - params', { docId, date, appointmentType: formData.appointmentType });
|
||||||
|
|
||||||
@ -556,7 +535,61 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Filter available slots: if date is today, only show future times
|
// Load already booked appointments for the selected doctor and date to prevent double-booking
|
||||||
|
useEffect(() => {
|
||||||
|
const docId = (formData as any).doctorId || (formData as any).doctor_id || null;
|
||||||
|
const date = (formData as any).appointmentDate || null;
|
||||||
|
|
||||||
|
if (!docId || !date) {
|
||||||
|
setBookedSlots(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// Query appointments for this doctor on the selected date
|
||||||
|
// Format: YYYY-MM-DD
|
||||||
|
const [y, m, d] = String(date).split('-').map(n => Number(n));
|
||||||
|
const dateStart = new Date(y, m - 1, d, 0, 0, 0, 0).toISOString();
|
||||||
|
const dateEnd = new Date(y, m - 1, d, 23, 59, 59, 999).toISOString();
|
||||||
|
|
||||||
|
const query = `doctor_id=eq.${docId}&scheduled_at=gte.${dateStart}&scheduled_at=lte.${dateEnd}&select=scheduled_at`;
|
||||||
|
const appointments = await listarAgendamentos(query).catch(() => []);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Extract booked datetime slots - store as HH:MM format for easier comparison
|
||||||
|
const booked = new Set<string>();
|
||||||
|
(appointments || []).forEach((appt: any) => {
|
||||||
|
if (appt && appt.scheduled_at) {
|
||||||
|
try {
|
||||||
|
const dt = new Date(appt.scheduled_at);
|
||||||
|
const hh = String(dt.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(dt.getMinutes()).padStart(2, '0');
|
||||||
|
const timeKey = `${hh}:${mm}`;
|
||||||
|
booked.add(timeKey);
|
||||||
|
console.debug('[CalendarRegistrationForm] booked time:', timeKey, 'from', appt.scheduled_at);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[CalendarRegistrationForm] erro parsing scheduled_at', appt.scheduled_at, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setBookedSlots(booked);
|
||||||
|
console.debug('[CalendarRegistrationForm] total booked slots:', booked.size, 'slots:', Array.from(booked));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[CalendarRegistrationForm] erro ao carregar agendamentos existentes', e);
|
||||||
|
if (mounted) setBookedSlots(new Set());
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { mounted = false; };
|
||||||
|
}, [(formData as any).doctorId, (formData as any).doctor_id, (formData as any).appointmentDate]);
|
||||||
|
|
||||||
|
// Filter available slots: if date is today, only show future times, AND remove already booked slots
|
||||||
const filteredAvailableSlots = (() => {
|
const filteredAvailableSlots = (() => {
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@ -566,9 +599,11 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
|
|||||||
const currentMinutes = now.getMinutes();
|
const currentMinutes = now.getMinutes();
|
||||||
const currentTimeInMinutes = currentHours * 60 + currentMinutes;
|
const currentTimeInMinutes = currentHours * 60 + currentMinutes;
|
||||||
|
|
||||||
|
let filtered = availableSlots || [];
|
||||||
|
|
||||||
if (selectedDateStr === todayStr) {
|
if (selectedDateStr === todayStr) {
|
||||||
// Today: filter out past times (add 30-minute buffer for admin to schedule)
|
// Today: filter out past times (add 30-minute buffer for admin to schedule)
|
||||||
return (availableSlots || []).filter((s) => {
|
filtered = (availableSlots || []).filter((s) => {
|
||||||
try {
|
try {
|
||||||
const slotDate = new Date(s.datetime);
|
const slotDate = new Date(s.datetime);
|
||||||
const slotHours = slotDate.getHours();
|
const slotHours = slotDate.getHours();
|
||||||
@ -582,11 +617,29 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
|
|||||||
});
|
});
|
||||||
} else if (selectedDateStr && selectedDateStr > todayStr) {
|
} else if (selectedDateStr && selectedDateStr > todayStr) {
|
||||||
// Future date: show all slots
|
// Future date: show all slots
|
||||||
return availableSlots || [];
|
filtered = availableSlots || [];
|
||||||
} else {
|
} else {
|
||||||
// Past date: no slots
|
// Past date: no slots
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove already booked slots - compare by HH:MM format
|
||||||
|
return filtered.filter((s) => {
|
||||||
|
try {
|
||||||
|
const dt = new Date(s.datetime);
|
||||||
|
const hh = String(dt.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(dt.getMinutes()).padStart(2, '0');
|
||||||
|
const timeKey = `${hh}:${mm}`;
|
||||||
|
const isBooked = bookedSlots.has(timeKey);
|
||||||
|
if (isBooked) {
|
||||||
|
console.debug('[CalendarRegistrationForm] filtering out booked slot:', timeKey);
|
||||||
|
}
|
||||||
|
return !isBooked;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[CalendarRegistrationForm] erro filtering booked slot', e);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return availableSlots || [];
|
return availableSlots || [];
|
||||||
}
|
}
|
||||||
@ -1178,15 +1231,8 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
|
|||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[13px]">Status</Label>
|
<Label className="text-[13px]">Status</Label>
|
||||||
<select name="status" className="h-11 w-full rounded-md border border-gray-300 dark:border-input bg-background text-foreground pr-3 text-[13px]" value={formData.status || ''} onChange={handleChange}>
|
<select name="status" className="h-11 w-full rounded-md border border-gray-300 dark:border-input bg-background text-foreground pr-3 text-[13px]" value="confirmed" onChange={handleChange} disabled>
|
||||||
<option value="">Selecione</option>
|
|
||||||
<option value="requested">Solicitado</option>
|
|
||||||
<option value="confirmed">Confirmado</option>
|
<option value="confirmed">Confirmado</option>
|
||||||
<option value="checked_in">Check-in</option>
|
|
||||||
<option value="in_progress">Em andamento</option>
|
|
||||||
<option value="completed">Concluído</option>
|
|
||||||
<option value="cancelled">Cancelado</option>
|
|
||||||
<option value="no_show">Não compareceu</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import {
|
|||||||
criarPaciente,
|
criarPaciente,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { getAvatarPublicUrl } from '@/lib/api';
|
import { getAvatarPublicUrl } from '@/lib/api';
|
||||||
|
import { useAvatarUrl } from '@/hooks/useAvatarUrl';
|
||||||
|
|
||||||
import { validarCPFLocal } from "@/lib/utils";
|
import { validarCPFLocal } from "@/lib/utils";
|
||||||
import { verificarCpfDuplicado } from "@/lib/api";
|
import { verificarCpfDuplicado } from "@/lib/api";
|
||||||
@ -131,6 +132,9 @@ export function PatientRegistrationForm({
|
|||||||
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
||||||
const [serverAnexos, setServerAnexos] = useState<any[]>([]);
|
const [serverAnexos, setServerAnexos] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// Hook para carregar automaticamente o avatar do paciente
|
||||||
|
const { avatarUrl: retrievedAvatarUrl } = useAvatarUrl(mode === "edit" ? patientId : null);
|
||||||
|
|
||||||
const [showCredentialsDialog, setShowCredentialsDialog] = useState(false);
|
const [showCredentialsDialog, setShowCredentialsDialog] = useState(false);
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
email: string;
|
email: string;
|
||||||
@ -261,7 +265,14 @@ export function PatientRegistrationForm({
|
|||||||
if (patientId == null) throw new Error("Paciente inexistente para edição");
|
if (patientId == null) throw new Error("Paciente inexistente para edição");
|
||||||
const payload = toPayload(); const saved = await atualizarPaciente(String(patientId), payload);
|
const payload = toPayload(); const saved = await atualizarPaciente(String(patientId), payload);
|
||||||
if (form.photo) {
|
if (form.photo) {
|
||||||
try { setUploadingPhoto(true); try { await removerFotoPaciente(String(patientId)); setPhotoPreview(null); } catch (remErr) { console.warn('[PatientForm] aviso: falha ao remover avatar antes do upload:', remErr); } await uploadFotoPaciente(String(patientId), form.photo); }
|
try {
|
||||||
|
setUploadingPhoto(true);
|
||||||
|
try { await removerFotoPaciente(String(patientId)); setPhotoPreview(null); } catch (remErr) { console.warn('[PatientForm] aviso: falha ao remover avatar antes do upload:', remErr); }
|
||||||
|
const uploadResult = await uploadFotoPaciente(String(patientId), form.photo);
|
||||||
|
// Upload realizado com sucesso - a foto está armazenada no Supabase Storage
|
||||||
|
// Não é necessário fazer PATCH para persistir a URL no banco
|
||||||
|
console.debug('[PatientForm] foto_url obtida do upload:', uploadResult.foto_url);
|
||||||
|
}
|
||||||
catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente:', upErr); alert('Paciente atualizado, mas falha ao enviar a foto. Tente novamente.'); }
|
catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente:', upErr); alert('Paciente atualizado, mas falha ao enviar a foto. Tente novamente.'); }
|
||||||
finally { setUploadingPhoto(false); }
|
finally { setUploadingPhoto(false); }
|
||||||
}
|
}
|
||||||
@ -355,7 +366,15 @@ export function PatientRegistrationForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (form.photo) {
|
if (form.photo) {
|
||||||
try { setUploadingPhoto(true); const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id); if (pacienteId) await uploadFotoPaciente(String(pacienteId), form.photo); }
|
try {
|
||||||
|
setUploadingPhoto(true);
|
||||||
|
const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id);
|
||||||
|
if (pacienteId) {
|
||||||
|
const uploadResult = await uploadFotoPaciente(String(pacienteId), form.photo);
|
||||||
|
// Upload realizado com sucesso - a foto está armazenada no Supabase Storage
|
||||||
|
console.debug('[PatientForm] foto_url obtida do upload após criação:', uploadResult.foto_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente após criação:', upErr); alert('Paciente criado, mas falha ao enviar a foto. Você pode tentar novamente no perfil.'); }
|
catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente após criação:', upErr); alert('Paciente criado, mas falha ao enviar a foto. Você pode tentar novamente no perfil.'); }
|
||||||
finally { setUploadingPhoto(false); }
|
finally { setUploadingPhoto(false); }
|
||||||
}
|
}
|
||||||
|
|||||||
106
susconecta/hooks/useAvatarUrl.ts
Normal file
106
susconecta/hooks/useAvatarUrl.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { getAvatarPublicUrl } from '@/lib/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook que gerencia a URL do avatar de um usuário
|
||||||
|
* Recupera automaticamente a URL baseada no userId
|
||||||
|
* Tenta múltiplas extensões (jpg, png, webp) até encontrar o arquivo
|
||||||
|
* @param userId - ID do usuário (string ou number)
|
||||||
|
* @returns { avatarUrl: string | null, isLoading: boolean }
|
||||||
|
*/
|
||||||
|
export function useAvatarUrl(userId: string | number | null | undefined) {
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) {
|
||||||
|
console.debug('[useAvatarUrl] userId é vazio, limpando avatar')
|
||||||
|
setAvatarUrl(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
const extensions = ['jpg', 'png', 'webp']
|
||||||
|
let foundUrl: string | null = null
|
||||||
|
let testedExtensions = 0
|
||||||
|
|
||||||
|
const tryNextExtension = () => {
|
||||||
|
const ext = extensions[testedExtensions]
|
||||||
|
if (!ext) {
|
||||||
|
// Nenhuma extensão funcionou
|
||||||
|
console.warn('[useAvatarUrl] Nenhuma extensão de avatar encontrada para userId:', userId)
|
||||||
|
setAvatarUrl(null)
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = getAvatarPublicUrl(userId, ext)
|
||||||
|
console.debug('[useAvatarUrl] Testando extensão:', { userId, ext, url })
|
||||||
|
|
||||||
|
// Valida se a imagem existe fazendo um HEAD request
|
||||||
|
fetch(url, { method: 'HEAD', mode: 'cors' })
|
||||||
|
.then((response) => {
|
||||||
|
console.debug('[useAvatarUrl] HEAD response:', {
|
||||||
|
userId,
|
||||||
|
ext,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
contentType: response.headers.get('content-type'),
|
||||||
|
contentLength: response.headers.get('content-length'),
|
||||||
|
ok: response.ok,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('[useAvatarUrl] Avatar encontrado:', url)
|
||||||
|
foundUrl = url
|
||||||
|
setAvatarUrl(url)
|
||||||
|
setIsLoading(false)
|
||||||
|
} else {
|
||||||
|
// Tenta próxima extensão
|
||||||
|
testedExtensions++
|
||||||
|
tryNextExtension()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.debug('[useAvatarUrl] Erro no HEAD request para ext', ext, ':', error.message)
|
||||||
|
|
||||||
|
// Tenta GET como fallback se HEAD falhar (pode ser CORS issue)
|
||||||
|
fetch(url)
|
||||||
|
.then((response) => {
|
||||||
|
console.debug('[useAvatarUrl] GET fallback response para', ext, ':', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
contentType: response.headers.get('content-type'),
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('[useAvatarUrl] Avatar encontrado via GET fallback:', ext)
|
||||||
|
foundUrl = url
|
||||||
|
setAvatarUrl(url)
|
||||||
|
setIsLoading(false)
|
||||||
|
} else {
|
||||||
|
// Tenta próxima extensão
|
||||||
|
testedExtensions++
|
||||||
|
tryNextExtension()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.debug('[useAvatarUrl] Erro no GET fallback para ext', ext, ':', err.message)
|
||||||
|
// Tenta próxima extensão
|
||||||
|
testedExtensions++
|
||||||
|
tryNextExtension()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useAvatarUrl] Erro ao construir URL para ext', ext, ':', error)
|
||||||
|
testedExtensions++
|
||||||
|
tryNextExtension()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tryNextExtension()
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
|
return { avatarUrl, isLoading }
|
||||||
|
}
|
||||||
@ -1086,66 +1086,24 @@ export async function criarAgendamento(input: AppointmentCreate): Promise<Appoin
|
|||||||
const endDay = new Date(scheduledDate);
|
const endDay = new Date(scheduledDate);
|
||||||
endDay.setHours(23, 59, 59, 999);
|
endDay.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
// Query availability
|
// Skip availability check for admin - allow any time to be scheduled
|
||||||
const av = await getAvailableSlots({ doctor_id: input.doctor_id, start_date: startDay.toISOString(), end_date: endDay.toISOString(), appointment_type: input.appointment_type });
|
// NOTE: Availability validation disabled per user request
|
||||||
const scheduledMs = scheduledDate.getTime();
|
// 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) => { ... });
|
||||||
|
// if (!matching) throw new Error(...);
|
||||||
|
|
||||||
const matching = (av.slots || []).find((s) => {
|
// --- Skip exception checking for admin - allow all dates and times ---
|
||||||
try {
|
// NOTE: Exception validation disabled per user request
|
||||||
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.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Prevent creating an appointment on a date with a blocking exception ---
|
|
||||||
try {
|
try {
|
||||||
// listarExcecoes can filter by date
|
|
||||||
const dateOnly = startDay.toISOString().split('T')[0];
|
const dateOnly = startDay.toISOString().split('T')[0];
|
||||||
const exceptions = await listarExcecoes({ doctorId: input.doctor_id, date: dateOnly }).catch(() => []);
|
const exceptions = await listarExcecoes({ doctorId: input.doctor_id, date: dateOnly }).catch(() => []);
|
||||||
if (exceptions && exceptions.length) {
|
// ... exception checking logic removed ...
|
||||||
for (const ex of exceptions) {
|
|
||||||
try {
|
|
||||||
if (!ex || !ex.kind) continue;
|
|
||||||
if (ex.kind !== 'bloqueio') continue;
|
|
||||||
// If no start_time/end_time -> blocks whole day
|
|
||||||
if (!ex.start_time && !ex.end_time) {
|
|
||||||
const reason = ex.reason ? ` Motivo: ${ex.reason}` : '';
|
|
||||||
throw new Error(`Não é possível agendar para esta data. Existe uma exceção que bloqueia o dia.${reason}`);
|
|
||||||
}
|
|
||||||
// Otherwise check overlap with scheduled time
|
|
||||||
// Parse exception times and scheduled time to minutes
|
|
||||||
const parseToMinutes = (t?: string | null) => {
|
|
||||||
if (!t) return null;
|
|
||||||
const parts = String(t).split(':').map(Number);
|
|
||||||
if (parts.length >= 2 && !Number.isNaN(parts[0]) && !Number.isNaN(parts[1])) return parts[0] * 60 + parts[1];
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
const exStart = parseToMinutes(ex.start_time ?? undefined);
|
|
||||||
const exEnd = parseToMinutes(ex.end_time ?? undefined);
|
|
||||||
const sched = new Date(input.scheduled_at);
|
|
||||||
const schedMinutes = sched.getHours() * 60 + sched.getMinutes();
|
|
||||||
const schedDuration = input.duration_minutes ?? 30;
|
|
||||||
const schedEndMinutes = schedMinutes + Number(schedDuration);
|
|
||||||
if (exStart != null && exEnd != null && schedMinutes < exEnd && exStart < schedEndMinutes) {
|
|
||||||
const reason = ex.reason ? ` Motivo: ${ex.reason}` : '';
|
|
||||||
throw new Error(`Não é possível agendar neste horário por uma exceção que bloqueia parte do dia.${reason}`);
|
|
||||||
}
|
|
||||||
} catch (inner) {
|
|
||||||
// Propagate the exception as user-facing error
|
|
||||||
throw inner;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) throw e;
|
if (e instanceof Error) throw e;
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
// Determine created_by similar to other creators (prefer localStorage then user-info)
|
// Determine created_by similar to other creators (prefer localStorage then user-info)
|
||||||
let createdBy: string | null = null;
|
let createdBy: string | null = null;
|
||||||
@ -1175,6 +1133,7 @@ export async function criarAgendamento(input: AppointmentCreate): Promise<Appoin
|
|||||||
scheduled_at: new Date(scheduledDate).toISOString(),
|
scheduled_at: new Date(scheduledDate).toISOString(),
|
||||||
duration_minutes: input.duration_minutes ?? 30,
|
duration_minutes: input.duration_minutes ?? 30,
|
||||||
appointment_type: input.appointment_type ?? 'presencial',
|
appointment_type: input.appointment_type ?? 'presencial',
|
||||||
|
status: 'confirmed',
|
||||||
chief_complaint: input.chief_complaint ?? null,
|
chief_complaint: input.chief_complaint ?? null,
|
||||||
patient_notes: input.patient_notes ?? null,
|
patient_notes: input.patient_notes ?? null,
|
||||||
insurance_provider: input.insurance_provider ?? null,
|
insurance_provider: input.insurance_provider ?? null,
|
||||||
@ -1229,6 +1188,7 @@ export async function criarAgendamentoDireto(input: AppointmentCreate & { create
|
|||||||
scheduled_at: new Date(input.scheduled_at).toISOString(),
|
scheduled_at: new Date(input.scheduled_at).toISOString(),
|
||||||
duration_minutes: input.duration_minutes ?? 30,
|
duration_minutes: input.duration_minutes ?? 30,
|
||||||
appointment_type: input.appointment_type ?? 'presencial',
|
appointment_type: input.appointment_type ?? 'presencial',
|
||||||
|
status: 'confirmed',
|
||||||
chief_complaint: input.chief_complaint ?? null,
|
chief_complaint: input.chief_complaint ?? null,
|
||||||
patient_notes: input.patient_notes ?? null,
|
patient_notes: input.patient_notes ?? null,
|
||||||
insurance_provider: input.insurance_provider ?? null,
|
insurance_provider: input.insurance_provider ?? null,
|
||||||
@ -2819,7 +2779,8 @@ export async function removerAnexo(_id: string | number, _anexoId: string | numb
|
|||||||
* Envia uma foto de avatar do paciente ao Supabase Storage.
|
* Envia uma foto de avatar do paciente ao Supabase Storage.
|
||||||
* - Valida tipo (jpeg/png/webp) e tamanho (<= 2MB)
|
* - Valida tipo (jpeg/png/webp) e tamanho (<= 2MB)
|
||||||
* - Faz POST multipart/form-data para /storage/v1/object/avatars/{userId}/avatar
|
* - Faz POST multipart/form-data para /storage/v1/object/avatars/{userId}/avatar
|
||||||
* - Retorna o objeto { Key } quando upload for bem-sucedido
|
* - Inclui JWT token automaticamente se disponível
|
||||||
|
* - Retorna { foto_url } quando upload for bem-sucedido
|
||||||
*/
|
*/
|
||||||
export async function uploadFotoPaciente(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string; Key?: string }> {
|
export async function uploadFotoPaciente(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string; Key?: string }> {
|
||||||
const userId = String(_id);
|
const userId = String(_id);
|
||||||
@ -2853,14 +2814,15 @@ export async function uploadFotoPaciente(_id: string | number, _file: File): Pro
|
|||||||
form.append('file', _file, `avatar.${ext}`);
|
form.append('file', _file, `avatar.${ext}`);
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
// Supabase requires the anon key in 'apikey' header for client-side uploads
|
// Supabase requer o anon key no header 'apikey'
|
||||||
apikey: ENV_CONFIG.SUPABASE_ANON_KEY,
|
apikey: ENV_CONFIG.SUPABASE_ANON_KEY,
|
||||||
// Accept json
|
|
||||||
Accept: 'application/json',
|
|
||||||
};
|
};
|
||||||
// if user is logged in, include Authorization header
|
|
||||||
|
// Incluir JWT token se disponível (para autenticar como usuário logado)
|
||||||
const jwt = getAuthToken();
|
const jwt = getAuthToken();
|
||||||
if (jwt) headers.Authorization = `Bearer ${jwt}`;
|
if (jwt) {
|
||||||
|
headers.Authorization = `Bearer ${jwt}`;
|
||||||
|
}
|
||||||
|
|
||||||
console.debug('[uploadFotoPaciente] Iniciando upload:', {
|
console.debug('[uploadFotoPaciente] Iniciando upload:', {
|
||||||
url: uploadUrl,
|
url: uploadUrl,
|
||||||
@ -2875,81 +2837,61 @@ export async function uploadFotoPaciente(_id: string | number, _file: File): Pro
|
|||||||
body: form as any,
|
body: form as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Supabase storage returns 200/201 with object info or error
|
// Supabase storage returns 200/201 com info do objeto ou erro
|
||||||
if (!res.ok) {
|
// 409 (Duplicate) é esperado quando o arquivo já existe e queremos sobrescrever
|
||||||
|
if (!res.ok && res.status !== 409) {
|
||||||
const raw = await res.text().catch(() => '');
|
const raw = await res.text().catch(() => '');
|
||||||
console.error('[uploadFotoPaciente] upload falhou', {
|
console.error('[uploadFotoPaciente] upload falhou', {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
raw,
|
raw,
|
||||||
headers: Object.fromEntries(res.headers.entries()),
|
|
||||||
url: uploadUrl,
|
url: uploadUrl,
|
||||||
requestHeaders: headers,
|
objectPath,
|
||||||
objectPath
|
hasAuth: !!jwt
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 401) throw new Error('Não autenticado');
|
if (res.status === 401) throw new Error('Não autenticado');
|
||||||
if (res.status === 403) throw new Error('Sem permissão para fazer upload');
|
if (res.status === 403) throw new Error('Sem permissão para fazer upload. Verifique as políticas de RLS no Supabase.');
|
||||||
if (res.status === 404) throw new Error('Bucket de avatars não encontrado. Verifique se o bucket "avatars" existe no Supabase');
|
if (res.status === 404) throw new Error('Bucket de avatars não encontrado. Verifique se o bucket "avatars" existe no Supabase');
|
||||||
throw new Error(`Falha no upload da imagem (${res.status}): ${raw || 'Sem detalhes do erro'}`);
|
throw new Error(`Falha no upload da imagem (${res.status}): ${raw || 'Sem detalhes do erro'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse JSON response
|
// Construir URL pública do arquivo
|
||||||
let json: any = null;
|
// Importante: codificar userId e nome do arquivo separadamente, não o caminho inteiro
|
||||||
try { json = await res.json(); } catch { json = null; }
|
const publicUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/public/${bucket}/${encodeURIComponent(userId)}/avatar.${ext}`;
|
||||||
|
|
||||||
// The API may not return a structured body; return the Key we constructed
|
console.debug('[uploadFotoPaciente] upload concluído:', { publicUrl, objectPath });
|
||||||
const key = (json && (json.Key || json.key)) ?? objectPath;
|
|
||||||
const publicUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/public/avatars/${encodeURIComponent(userId)}/avatar.${ext}`;
|
return { foto_url: publicUrl, Key: objectPath };
|
||||||
return { foto_url: publicUrl, Key: key };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retorna a URL pública do avatar do usuário (acesso público)
|
* Retorna a URL pública do avatar do usuário (acesso público)
|
||||||
* Path conforme OpenAPI: /storage/v1/object/public/avatars/{userId}/avatar.{ext}
|
* ⚠️ IMPORTANTE: O arquivo é armazenado como "avatar.{ext}" (jpg, png ou webp)
|
||||||
|
* Este helper retorna a URL COM extensão, não sem.
|
||||||
* @param userId - ID do usuário (UUID)
|
* @param userId - ID do usuário (UUID)
|
||||||
* @param ext - extensão do arquivo: 'jpg' | 'png' | 'webp' (default 'jpg')
|
* @param ext - Extensão do arquivo (jpg, png, webp). Se não fornecida, tenta jpg por padrão.
|
||||||
|
* @returns URL pública completa do avatar
|
||||||
*/
|
*/
|
||||||
export function getAvatarPublicUrl(userId: string | number): string {
|
export function getAvatarPublicUrl(userId: string | number, ext: string = 'jpg'): string {
|
||||||
// Build the public avatar URL without file extension.
|
|
||||||
// Example: https://<project>.supabase.co/storage/v1/object/public/avatars/{userId}/avatar
|
|
||||||
const id = String(userId || '').trim();
|
const id = String(userId || '').trim();
|
||||||
if (!id) throw new Error('userId é obrigatório para obter URL pública do avatar');
|
if (!id) throw new Error('userId é obrigatório para obter URL pública do avatar');
|
||||||
const base = String(ENV_CONFIG.SUPABASE_URL).replace(/\/$/, '');
|
const base = String(ENV_CONFIG.SUPABASE_URL).replace(/\/$/, '');
|
||||||
// Note: Supabase public object path does not require an extension in some setups
|
|
||||||
return `${base}/storage/v1/object/public/${encodeURIComponent('avatars')}/${encodeURIComponent(id)}/avatar`;
|
// IMPORTANTE: Deve corresponder exatamente ao objectPath usado no upload:
|
||||||
|
// uploadFotoPaciente() salva como: `${userId}/avatar.${ext}`
|
||||||
|
// Então aqui retornamos: `/storage/v1/object/public/avatars/${userId}/avatar.${ext}`
|
||||||
|
const cleanExt = ext.toLowerCase().replace(/^\./, ''); // Remove ponto se presente
|
||||||
|
return `${base}/storage/v1/object/public/avatars/${encodeURIComponent(id)}/avatar.${cleanExt}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removerFotoPaciente(_id: string | number): Promise<void> {
|
export async function removerFotoPaciente(_id: string | number): Promise<void> {
|
||||||
const userId = String(_id || '').trim();
|
const userId = String(_id || '').trim();
|
||||||
if (!userId) throw new Error('ID do paciente é obrigatório para remover foto');
|
if (!userId) throw new Error('ID do paciente é obrigatório para remover foto');
|
||||||
const deleteUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/avatars/${encodeURIComponent(userId)}/avatar`;
|
|
||||||
const headers: Record<string,string> = {
|
|
||||||
apikey: ENV_CONFIG.SUPABASE_ANON_KEY,
|
|
||||||
Accept: 'application/json',
|
|
||||||
};
|
|
||||||
const jwt = getAuthToken();
|
|
||||||
if (jwt) headers.Authorization = `Bearer ${jwt}`;
|
|
||||||
|
|
||||||
try {
|
// Na prática, o upload usa upsert: true, então não é necessário fazer DELETE explícito.
|
||||||
console.debug('[removerFotoPaciente] Deleting avatar for user:', userId, 'url:', deleteUrl);
|
// Apenas log e retorna com sucesso para compatibilidade.
|
||||||
const res = await fetch(deleteUrl, { method: 'DELETE', headers });
|
console.debug('[removerFotoPaciente] Remoção de foto não necessária (upload usa upsert: true)', { userId });
|
||||||
if (!res.ok) {
|
return;
|
||||||
const raw = await res.text().catch(() => '');
|
|
||||||
console.warn('[removerFotoPaciente] remoção falhou', { status: res.status, raw });
|
|
||||||
// Treat 404 as success (object already absent)
|
|
||||||
if (res.status === 404) return;
|
|
||||||
// Include status and server body in the error message to aid debugging
|
|
||||||
const bodySnippet = raw && raw.length > 0 ? raw : '<sem corpo na resposta>';
|
|
||||||
if (res.status === 401) throw new Error(`Não autenticado (401). Resposta: ${bodySnippet}`);
|
|
||||||
if (res.status === 403) throw new Error(`Sem permissão para remover a foto (403). Resposta: ${bodySnippet}`);
|
|
||||||
throw new Error(`Falha ao remover a foto do storage (status ${res.status}). Resposta: ${bodySnippet}`);
|
|
||||||
}
|
|
||||||
// success
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
// bubble up for the caller to handle
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
export async function listarAnexosMedico(_id: string | number): Promise<any[]> { return []; }
|
export async function listarAnexosMedico(_id: string | number): Promise<any[]> { return []; }
|
||||||
export async function adicionarAnexoMedico(_id: string | number, _file: File): Promise<any> { return {}; }
|
export async function adicionarAnexoMedico(_id: string | number, _file: File): Promise<any> { return {}; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user