Compare commits

...

11 Commits

9 changed files with 240 additions and 119 deletions

View File

@ -74,6 +74,20 @@ const capitalize = (s: string) => {
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() {
const [appointments, setAppointments] = useState<any[]>([]);
const [originalAppointments, setOriginalAppointments] = useState<any[]>([]);
@ -197,7 +211,7 @@ export default function ConsultasPage() {
const payload: any = {
scheduled_at,
duration_minutes,
status: formData.status || undefined,
status: 'confirmed',
notes: formData.notes ?? null,
chief_complaint: formData.chief_complaint ?? null,
patient_notes: formData.patient_notes ?? null,
@ -561,7 +575,7 @@ export default function ConsultasPage() {
}
className={appointment.status === "confirmed" ? "bg-green-600" : ""}
>
{capitalize(appointment.status)}
{translateStatus(appointment.status)}
</Badge>
</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" : ""}`}
>
{capitalize(appointment.status)}
{translateStatus(appointment.status)}
</Badge>
</div>
<div className="col-span-2">
@ -771,7 +785,7 @@ export default function ConsultasPage() {
}
className={viewingAppointment?.status === "confirmed" ? "bg-green-600" : ""}
>
{capitalize(viewingAppointment?.status || "")}
{translateStatus(viewingAppointment?.status || "")}
</Badge>
</span>
</div>

View File

@ -51,7 +51,7 @@ interface UserProfile {
export default function PerfilPage() {
const router = useRouter();
const { user: authUser } = useAuth();
const { user: authUser, updateUserProfile } = useAuth();
const [userInfo, setUserInfo] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -229,6 +229,31 @@ export default function PerfilPage() {
} : null,
} : null
);
// Also update global auth profile so header/avatar updates immediately
try {
if (typeof updateUserProfile === 'function') {
updateUserProfile({
// Persist common keys used across the app
foto_url: editingData.avatar_url || undefined,
telefone: editingData.phone || undefined
});
} else {
// Fallback: try to persist directly to localStorage so next reload shows it
try {
const raw = localStorage.getItem('auth_user')
if (raw) {
const u = JSON.parse(raw)
u.profile = u.profile || {}
if (editingData.avatar_url) { u.profile.foto_url = editingData.avatar_url; u.profile.avatar_url = editingData.avatar_url }
if (editingData.phone) u.profile.telefone = editingData.phone
localStorage.setItem('auth_user', JSON.stringify(u))
}
} catch (_e) {}
}
} catch (err) {
console.warn('[PERFIL] Falha ao sincronizar profile global:', err)
}
} catch (err: any) {
console.error('[PERFIL] Erro ao salvar:', err);
}
@ -296,7 +321,7 @@ export default function PerfilPage() {
className="bg-blue-600 hover:bg-blue-700"
onClick={handleEditClick}
>
Editar Perfil
Editar Perfil
</Button>
) : (
<div className="flex gap-2">
@ -591,7 +616,25 @@ export default function PerfilPage() {
<UploadAvatar
userId={userInfo.user.id}
currentAvatarUrl={editingData.avatar_url || userInfo.profile?.avatar_url || "/avatars/01.png"}
onAvatarChange={(newUrl) => setEditingData({...editingData, avatar_url: newUrl})}
onAvatarChange={(newUrl) => {
setEditingData({...editingData, avatar_url: newUrl})
try {
if (typeof updateUserProfile === 'function') {
updateUserProfile({ foto_url: newUrl })
} else {
const raw = localStorage.getItem('auth_user')
if (raw) {
const u = JSON.parse(raw)
u.profile = u.profile || {}
u.profile.foto_url = newUrl
u.profile.avatar_url = newUrl
localStorage.setItem('auth_user', JSON.stringify(u))
}
}
} catch (err) {
console.warn('[PERFIL] erro ao persistir avatar no auth_user localStorage', err)
}
}}
userName={editingData.full_name || userInfo.profile?.full_name || "Usuário"}
/>
</div>

View File

@ -826,7 +826,7 @@ export default function PacientePage() {
<div className="flex items-start gap-3 sm:gap-4 min-w-0">
<span
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
/>
<div className="space-y-2 sm:space-y-3 min-w-0">
@ -851,10 +851,8 @@ export default function PacientePage() {
{/* Status Badge */}
<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 ${
consulta.status === 'Confirmada'
? 'bg-linear-to-r from-emerald-500 to-emerald-600 shadow-emerald-500/20'
: consulta.status === 'Pendente'
? 'bg-linear-to-r from-amber-500 to-amber-600 shadow-amber-500/20'
consulta.status === 'Confirmada' || consulta.status === 'confirmed'
? 'bg-linear-to-r from-green-500 to-green-600 shadow-green-500/20'
: 'bg-linear-to-r from-red-500 to-red-600 shadow-red-500/20'
}`}>
{statusLabel(consulta.status)}
@ -872,7 +870,7 @@ export default function PacientePage() {
Detalhes
</Button>
{/* Reagendar removed by request */}
{consulta.status !== 'Cancelada' && (
{consulta.status !== 'Cancelada' && consulta.status !== 'cancelled' && (
<Button
type="button"
size="sm"
@ -881,17 +879,33 @@ export default function PacientePage() {
try {
const ok = typeof window !== 'undefined' ? window.confirm('Deseja realmente cancelar esta consulta?') : true
if (!ok) return
// call API to delete
await deletarAgendamento(consulta.id)
// Mark as deleted in cache so it won't appear again
addDeletedAppointmentId(consulta.id)
// remove from local list
// Prefer PATCH to mark appointment as cancelled (safer under RLS)
try {
await atualizarAgendamento(consulta.id, {
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) => {
if (!prev) return prev
return prev.filter((a: any) => String(a.id) !== String(consulta.id))
})
// if modal open for this appointment, close it
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.' })
} catch (err: any) {
console.error('[Consultas] falha ao cancelar agendamento', err)
@ -1576,7 +1590,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"
onClick={() => setIsEditingProfile(true)}
>
Editar Perfil
Editar Perfil
</Button>
) : (
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">

View File

@ -2766,7 +2766,7 @@ const ProfissionalPage = () => {
className="bg-blue-600 hover:bg-blue-700 text-xs sm:text-sm w-full sm:w-auto"
onClick={() => setIsEditingProfile(true)}
>
Editar Perfil
Editar Perfil
</Button>
) : (
<div className="flex gap-2 w-full sm:w-auto">

View File

@ -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"
onClick={() => setDropdownOpen(!dropdownOpen)}
>
{/* Mostrar foto do usuário quando disponível; senão, mostrar fallback com iniciais */}
<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>
</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"
>
👤 Perfil
</button>
<button className="w-full text-left px-4 py-2 text-sm hover:bg-accent cursor-pointer">
Configurações
Perfil
</button>
<div className="border-t border-border my-1"></div>
<button
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"
>
🚪 Sair
Sair
</button>
</div>
</div>

View File

@ -2,7 +2,7 @@
"use client";
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 {
AlertDialog,
@ -93,6 +93,7 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
const [exceptionDialogOpen, setExceptionDialogOpen] = useState(false);
const [exceptionDialogMessage, setExceptionDialogMessage] = useState<string | null>(null);
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
const isoToDatetimeLocal = (iso?: string | null) => {
@ -298,31 +299,9 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
if (mountedRef.current) setLoadingSlots(true);
try {
// Check for blocking exceptions first
try {
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);
}
// Skip exception checking - all dates are available for admin now
// NOTE: Exception checking disabled per user request
console.debug('[CalendarRegistrationForm] getAvailableSlots - params', { docId, date, appointmentType: formData.appointmentType });
// Build local start/end for the day
@ -556,7 +535,61 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
// 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 = (() => {
try {
const now = new Date();
@ -566,9 +599,11 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
const currentMinutes = now.getMinutes();
const currentTimeInMinutes = currentHours * 60 + currentMinutes;
let filtered = availableSlots || [];
if (selectedDateStr === todayStr) {
// Today: filter out past times (add 30-minute buffer for admin to schedule)
return (availableSlots || []).filter((s) => {
filtered = (availableSlots || []).filter((s) => {
try {
const slotDate = new Date(s.datetime);
const slotHours = slotDate.getHours();
@ -582,11 +617,29 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
});
} else if (selectedDateStr && selectedDateStr > todayStr) {
// Future date: show all slots
return availableSlots || [];
filtered = availableSlots || [];
} else {
// Past date: no slots
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) {
return availableSlots || [];
}
@ -1178,15 +1231,8 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<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}>
<option value="">Selecione</option>
<option value="requested">Solicitado</option>
<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="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>
</div>
<div className="space-y-1">

View File

@ -336,6 +336,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, [user?.userType, token, clearAuthData])
// Allow updating the in-memory user profile and persist to localStorage.
const updateUserProfile = useCallback((partial: Partial<UserData['profile']>) => {
try {
setUser((prev) => {
if (!prev) return prev
const next = { ...prev, profile: { ...(prev.profile || {}), ...(partial || {}) } }
try {
if (typeof window !== 'undefined') {
localStorage.setItem(AUTH_STORAGE_KEYS.USER, JSON.stringify(next))
}
} catch (e) {
console.warn('[AUTH] Falha ao persistir user atualizado no localStorage:', e)
}
return next
})
} catch (err) {
console.warn('[AUTH] updateUserProfile erro:', err)
}
}, [])
// Refresh token memoizado (usado pelo HTTP client)
const refreshToken = useCallback(async (): Promise<boolean> => {
// Esta função é principalmente para compatibilidade
@ -350,8 +370,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
token,
login,
logout,
refreshToken
}), [authStatus, user, token, login, logout, refreshToken])
refreshToken,
updateUserProfile
}), [authStatus, user, token, login, logout, refreshToken, updateUserProfile])
// Inicialização única
useEffect(() => {

View File

@ -1086,66 +1086,24 @@ export async function criarAgendamento(input: AppointmentCreate): Promise<Appoin
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();
// Skip availability check for admin - allow any time to be scheduled
// NOTE: Availability validation disabled per user request
// 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) => {
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.');
}
// --- Prevent creating an appointment on a date with a blocking exception ---
// --- Skip exception checking for admin - allow all dates and times ---
// NOTE: Exception validation disabled per user request
/*
try {
// listarExcecoes can filter by date
const dateOnly = startDay.toISOString().split('T')[0];
const exceptions = await listarExcecoes({ doctorId: input.doctor_id, date: dateOnly }).catch(() => []);
if (exceptions && exceptions.length) {
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;
}
}
}
// ... exception checking logic removed ...
} catch (e) {
if (e instanceof Error) throw e;
}
*/
// Determine created_by similar to other creators (prefer localStorage then user-info)
let createdBy: string | null = null;
@ -1175,6 +1133,7 @@ export async function criarAgendamento(input: AppointmentCreate): Promise<Appoin
scheduled_at: new Date(scheduledDate).toISOString(),
duration_minutes: input.duration_minutes ?? 30,
appointment_type: input.appointment_type ?? 'presencial',
status: 'confirmed',
chief_complaint: input.chief_complaint ?? null,
patient_notes: input.patient_notes ?? 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(),
duration_minutes: input.duration_minutes ?? 30,
appointment_type: input.appointment_type ?? 'presencial',
status: 'confirmed',
chief_complaint: input.chief_complaint ?? null,
patient_notes: input.patient_notes ?? null,
insurance_provider: input.insurance_provider ?? null,

View File

@ -51,6 +51,8 @@ export interface AuthContextType {
login: (email: string, password: string, userType: UserType) => Promise<boolean>
logout: () => Promise<void>
refreshToken: () => Promise<boolean>
// Merge partial profile into the stored user (client-side convenience)
updateUserProfile?: (partial: Partial<UserData['profile']>) => void
}
export interface AuthStorageKeys {