develop #83

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

View File

@ -78,6 +78,8 @@ export default function ConsultasPage() {
const [appointments, setAppointments] = useState<any[]>([]); const [appointments, setAppointments] = useState<any[]>([]);
const [originalAppointments, setOriginalAppointments] = useState<any[]>([]); const [originalAppointments, setOriginalAppointments] = useState<any[]>([]);
const [searchValue, setSearchValue] = useState<string>(''); const [searchValue, setSearchValue] = useState<string>('');
const [selectedStatus, setSelectedStatus] = useState<string>('all');
const [filterDate, setFilterDate] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingAppointment, setEditingAppointment] = useState<any | null>(null); const [editingAppointment, setEditingAppointment] = useState<any | null>(null);
@ -101,6 +103,8 @@ export default function ConsultasPage() {
id: appointment.id, id: appointment.id,
patientName: appointment.patient, patientName: appointment.patient,
patientId: appointment.patient_id || appointment.patientId || null, patientId: appointment.patient_id || appointment.patientId || null,
// include doctor id so the form can run availability/exception checks when editing
doctorId: appointment.doctor_id || appointment.doctorId || null,
professionalName: appointment.professional || "", professionalName: appointment.professional || "",
appointmentDate: appointmentDateStr, appointmentDate: appointmentDateStr,
startTime, startTime,
@ -208,6 +212,8 @@ export default function ConsultasPage() {
id: updated.id, id: updated.id,
patient: formData.patientName || existing.patient || '', patient: formData.patientName || existing.patient || '',
patient_id: existing.patient_id ?? null, patient_id: existing.patient_id ?? null,
// preserve doctor id so future edits retain the selected professional
doctor_id: existing.doctor_id ?? (formData.doctorId || (formData as any).doctor_id) ?? null,
// preserve server-side fields so future edits read them // preserve server-side fields so future edits read them
scheduled_at: updated.scheduled_at ?? scheduled_at, scheduled_at: updated.scheduled_at ?? scheduled_at,
duration_minutes: updated.duration_minutes ?? duration_minutes, duration_minutes: updated.duration_minutes ?? duration_minutes,
@ -280,6 +286,8 @@ export default function ConsultasPage() {
id: a.id, id: a.id,
patient, patient,
patient_id: a.patient_id, patient_id: a.patient_id,
// preserve the doctor's id so later edit flows can access it
doctor_id: a.doctor_id ?? null,
// keep some server-side fields so edit can access them later // keep some server-side fields so edit can access them later
scheduled_at: a.scheduled_at ?? a.time ?? a.created_at ?? null, scheduled_at: a.scheduled_at ?? a.time ?? a.created_at ?? null,
duration_minutes: a.duration_minutes ?? a.duration ?? null, duration_minutes: a.duration_minutes ?? a.duration ?? null,
@ -323,15 +331,14 @@ export default function ConsultasPage() {
// Search box: allow fetching a single appointment by ID when pressing Enter // Search box: allow fetching a single appointment by ID when pressing Enter
// Perform a local-only search against the already-loaded appointments. // Perform a local-only search against the already-loaded appointments.
// This intentionally does not call the server — it filters the cached list. // This intentionally does not call the server — it filters the cached list.
const performSearch = (val: string) => { const applyFilters = (val?: string) => {
const trimmed = String(val || '').trim(); const trimmed = String((val ?? searchValue) || '').trim();
if (!trimmed) { let list = (originalAppointments || []).slice();
setAppointments(originalAppointments || []);
return;
}
// search
if (trimmed) {
const q = trimmed.toLowerCase(); const q = trimmed.toLowerCase();
const localMatches = (originalAppointments || []).filter((a) => { list = list.filter((a) => {
const patient = String(a.patient || '').toLowerCase(); const patient = String(a.patient || '').toLowerCase();
const professional = String(a.professional || '').toLowerCase(); const professional = String(a.professional || '').toLowerCase();
const pid = String(a.patient_id || '').toLowerCase(); const pid = String(a.patient_id || '').toLowerCase();
@ -343,10 +350,30 @@ export default function ConsultasPage() {
aid === q aid === q
); );
}); });
}
setAppointments(localMatches as any[]); // status filter
if (selectedStatus && selectedStatus !== 'all') {
list = list.filter((a) => String(a.status || '').toLowerCase() === String(selectedStatus).toLowerCase());
}
// date filter (YYYY-MM-DD)
if (filterDate) {
list = list.filter((a) => {
try {
const sched = a.scheduled_at || a.time || a.created_at || null;
if (!sched) return false;
const iso = new Date(sched).toISOString().split('T')[0];
return iso === filterDate;
} catch (e) { return false; }
});
}
setAppointments(list as any[]);
}; };
const performSearch = (val: string) => { applyFilters(val); };
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
@ -379,6 +406,10 @@ export default function ConsultasPage() {
return () => clearTimeout(t); return () => clearTimeout(t);
}, [searchValue, originalAppointments]); }, [searchValue, originalAppointments]);
useEffect(() => {
applyFilters();
}, [selectedStatus, filterDate, originalAppointments]);
// Keep localForm synchronized with editingAppointment // Keep localForm synchronized with editingAppointment
useEffect(() => { useEffect(() => {
if (showForm && editingAppointment) { if (showForm && editingAppointment) {
@ -404,7 +435,7 @@ export default function ConsultasPage() {
</Button> </Button>
<h1 className="text-lg font-semibold md:text-2xl">Editar Consulta</h1> <h1 className="text-lg font-semibold md:text-2xl">Editar Consulta</h1>
</div> </div>
<CalendarRegistrationForm formData={localForm} onFormChange={onFormChange} /> <CalendarRegistrationForm formData={localForm} onFormChange={onFormChange} createMode={true} />
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleCancel}> <Button variant="outline" onClick={handleCancel}>
Cancelar Cancelar
@ -451,18 +482,19 @@ export default function ConsultasPage() {
/> />
</div> </div>
</div> </div>
<Select> <Select onValueChange={(v) => { setSelectedStatus(String(v)); }}>
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filtrar por status" /> <SelectValue placeholder="Filtrar por status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Todos</SelectItem> <SelectItem value="all">Todos</SelectItem>
<SelectItem value="confirmed">Confirmada</SelectItem> <SelectItem value="confirmed">Confirmada</SelectItem>
<SelectItem value="pending">Pendente</SelectItem> {/* backend uses 'requested' for pending requests, map UI label to that value */}
<SelectItem value="requested">Pendente</SelectItem>
<SelectItem value="cancelled">Cancelada</SelectItem> <SelectItem value="cancelled">Cancelada</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Input type="date" className="w-[180px]" /> <Input type="date" className="w-[180px]" value={filterDate} onChange={(e) => setFilterDate(e.target.value)} />
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@ -86,6 +86,7 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
const [loadingAssignedDoctors, setLoadingAssignedDoctors] = useState(false); const [loadingAssignedDoctors, setLoadingAssignedDoctors] = useState(false);
const [loadingPatientsForDoctor, setLoadingPatientsForDoctor] = useState(false); const [loadingPatientsForDoctor, setLoadingPatientsForDoctor] = useState(false);
const [availableSlots, setAvailableSlots] = useState<Array<{ datetime: string; available: boolean; slot_minutes?: number }>>([]); const [availableSlots, setAvailableSlots] = useState<Array<{ datetime: string; available: boolean; slot_minutes?: number }>>([]);
const [availabilityWindows, setAvailabilityWindows] = useState<Array<{ winStart: Date; winEnd: Date; slotMinutes?: number }>>([]);
const [loadingSlots, setLoadingSlots] = useState(false); const [loadingSlots, setLoadingSlots] = useState(false);
const [lockedDurationFromSlot, setLockedDurationFromSlot] = useState(false); const [lockedDurationFromSlot, setLockedDurationFromSlot] = useState(false);
const [exceptionDialogOpen, setExceptionDialogOpen] = useState(false); const [exceptionDialogOpen, setExceptionDialogOpen] = useState(false);
@ -275,18 +276,27 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
}, [(formData as any).doctorId, (formData as any).doctor_id]); }, [(formData as any).doctorId, (formData as any).doctor_id]);
// When doctor or date changes, fetch available slots // When doctor or date changes, fetch available slots
// Keep a mounted ref to avoid setting state after unmount when reused
const mountedRef = useRef(true);
useEffect(() => { useEffect(() => {
const docId = (formData as any).doctorId || (formData as any).doctor_id || null; mountedRef.current = true;
const date = (formData as any).appointmentDate || null; return () => { mountedRef.current = false; };
}, []);
// Extract the availability + exceptions logic into a reusable function so we
// can call it both when dependencies change and once at mount-time when the
// form already contains doctor/date (covering the edit flow).
const fetchExceptionsAndSlots = async (docIdArg?: string | null, dateArg?: string | null) => {
const docId = docIdArg ?? ((formData as any).doctorId || (formData as any).doctor_id || null);
const date = dateArg ?? ((formData as any).appointmentDate || null);
if (!docId || !date) { if (!docId || !date) {
setAvailableSlots([]); if (mountedRef.current) setAvailableSlots([]);
return; return;
} }
let mounted = true; if (mountedRef.current) setLoadingSlots(true);
setLoadingSlots(true);
(async () => {
try { try {
// Check for blocking exceptions on this exact date before querying availability. // Check for blocking exceptions first
try { try {
const exceptions = await listarExcecoes({ doctorId: String(docId), date: String(date) }).catch(() => []); const exceptions = await listarExcecoes({ doctorId: String(docId), date: String(date) }).catch(() => []);
if (exceptions && exceptions.length) { if (exceptions && exceptions.length) {
@ -295,63 +305,54 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
const reason = blocking.reason ? ` Motivo: ${blocking.reason}` : ''; const reason = blocking.reason ? ` Motivo: ${blocking.reason}` : '';
const msg = `Não é possível agendar nesta data.${reason}`; const msg = `Não é possível agendar nesta data.${reason}`;
try { try {
// open modal dialog with message
setExceptionDialogMessage(msg); setExceptionDialogMessage(msg);
setExceptionDialogOpen(true); setExceptionDialogOpen(true);
} catch (e) { } catch (e) {
try { toast({ title: 'Data indisponível', description: msg }); } catch (ee) {} try { toast({ title: 'Data indisponível', description: msg }); } catch (ee) {}
} }
if (!mounted) return; if (mountedRef.current) {
setAvailableSlots([]); setAvailableSlots([]);
setLoadingSlots(false); setLoadingSlots(false);
}
return; return;
} }
} }
} catch (exCheckErr) { } catch (exCheckErr) {
// If the exceptions check fails for network reasons, proceed to availability fetch
console.warn('[CalendarRegistrationForm] listarExcecoes falhou, continuando para getAvailableSlots', 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 });
console.debug('[CalendarRegistrationForm] doctorOptions count', (doctorOptions || []).length, 'selectedDoctorId', docId, 'doctorOptions sample', (doctorOptions || []).slice(0,3));
// Build start/end as local day bounds from YYYY-MM-DD to avoid // Build local start/end for the day
// timezone/parsing issues (sending incorrect UTC offsets that shift
// the requested day to the previous/next calendar day).
// Expect `date` to be in format 'YYYY-MM-DD'. Parse explicitly.
let start: Date; let start: Date;
let end: Date; let end: Date;
try { try {
const parts = String(date).split('-').map((p) => Number(p)); const parts = String(date).split('-').map((p) => Number(p));
if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) { if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) {
const [y, m, d] = parts; const [y, m, d] = parts;
// new Date(y, m-1, d, hh, mm, ss, ms) constructs a date in the
// local timezone at the requested hour. toISOString() will then
// represent that local instant in UTC which is what the server
// expects for availability checks across timezones.
start = new Date(y, m - 1, d, 0, 0, 0, 0); start = new Date(y, m - 1, d, 0, 0, 0, 0);
end = new Date(y, m - 1, d, 23, 59, 59, 999); end = new Date(y, m - 1, d, 23, 59, 59, 999);
} else { } else {
// fallback to previous logic if parsing fails
start = new Date(date); start = new Date(date);
start.setHours(0,0,0,0); start.setHours(0,0,0,0);
end = new Date(date); end = new Date(date);
end.setHours(23,59,59,999); end.setHours(23,59,59,999);
} }
} catch (err) { } catch (err) {
// fallback safe behavior
start = new Date(date); start = new Date(date);
start.setHours(0,0,0,0); start.setHours(0,0,0,0);
end = new Date(date); end = new Date(date);
end.setHours(23,59,59,999); end.setHours(23,59,59,999);
} }
const av = await getAvailableSlots({ doctor_id: String(docId), start_date: start.toISOString(), end_date: end.toISOString(), appointment_type: formData.appointmentType || 'presencial' }); const av = await getAvailableSlots({ doctor_id: String(docId), start_date: start.toISOString(), end_date: end.toISOString(), appointment_type: formData.appointmentType || 'presencial' });
if (!mounted) return; if (!mountedRef.current) return;
console.debug('[CalendarRegistrationForm] getAvailableSlots - response slots count', (av && av.slots && av.slots.length) || 0, av); console.debug('[CalendarRegistrationForm] getAvailableSlots - response slots count', (av && av.slots && av.slots.length) || 0, av);
// Try to restrict the returned slots to the doctor's public availability windows // Try to restrict the returned slots to the doctor's public availability windows
try { try {
const disponibilidades = await listarDisponibilidades({ doctorId: String(docId) }).catch(() => []); const disponibilidades = await listarDisponibilidades({ doctorId: String(docId) }).catch(() => []);
const weekdayNumber = start.getDay(); // 0 (Sun) .. 6 (Sat) const weekdayNumber = start.getDay();
// map weekday number to possible representations (numeric, en, pt, abbrev)
const weekdayNames: Record<number, string[]> = { const weekdayNames: Record<number, string[]> = {
0: ['0', 'sun', 'sunday', 'domingo'], 0: ['0', 'sun', 'sunday', 'domingo'],
1: ['1', 'mon', 'monday', 'segunda', 'segunda-feira'], 1: ['1', 'mon', 'monday', 'segunda', 'segunda-feira'],
@ -363,25 +364,19 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
}; };
const allowed = (weekdayNames[weekdayNumber] || []).map((s) => String(s).toLowerCase()); const allowed = (weekdayNames[weekdayNumber] || []).map((s) => String(s).toLowerCase());
// Filter disponibilidades to those matching the weekday (try multiple fields)
const matched = (disponibilidades || []).filter((d: any) => { const matched = (disponibilidades || []).filter((d: any) => {
try { try {
const raw = String(d.weekday ?? d.weekday_name ?? d.day ?? d.day_of_week ?? '').toLowerCase(); const raw = String(d.weekday ?? d.weekday_name ?? d.day ?? d.day_of_week ?? '').toLowerCase();
if (!raw) return false; if (!raw) return false;
// direct numeric or name match
if (allowed.includes(raw)) return true; if (allowed.includes(raw)) return true;
// sometimes API returns numbers as integers
if (typeof d.weekday === 'number' && d.weekday === weekdayNumber) return true; if (typeof d.weekday === 'number' && d.weekday === weekdayNumber) return true;
if (typeof d.day_of_week === 'number' && d.day_of_week === weekdayNumber) return true; if (typeof d.day_of_week === 'number' && d.day_of_week === weekdayNumber) return true;
return false; return false;
} catch (e) { return false; } } catch (e) { return false; }
}); });
console.debug('[CalendarRegistrationForm] disponibilidades fetched', disponibilidades, 'matched for weekday', weekdayNumber, matched);
if (matched && matched.length) { if (matched && matched.length) {
// Build windows from matched disponibilidades and filter av.slots
const windows = matched.map((d: any) => { const windows = matched.map((d: any) => {
// d.start_time may be '09:00:00' or '09:00'
const parseTime = (t?: string) => { const parseTime = (t?: string) => {
if (!t) return { hh: 0, mm: 0, ss: 0 }; if (!t) return { hh: 0, mm: 0, ss: 0 };
const parts = String(t).split(':').map((p) => Number(p)); const parts = String(t).split(':').map((p) => Number(p));
@ -391,29 +386,27 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
const e2 = parseTime(d.end_time); const e2 = parseTime(d.end_time);
const winStart = new Date(start.getFullYear(), start.getMonth(), start.getDate(), s.hh, s.mm, s.ss || 0, 0); const winStart = new Date(start.getFullYear(), start.getMonth(), start.getDate(), s.hh, s.mm, s.ss || 0, 0);
const winEnd = new Date(start.getFullYear(), start.getMonth(), start.getDate(), e2.hh, e2.mm, e2.ss || 0, 999); const winEnd = new Date(start.getFullYear(), start.getMonth(), start.getDate(), e2.hh, e2.mm, e2.ss || 0, 999);
const slotMinutes = Number(d.slot_minutes || d.slot_minutes_minutes || null) || null; const slotMinutes = (() => { const n = Number(d.slot_minutes ?? d.slot_minutes_minutes ?? NaN); return Number.isFinite(n) ? n : undefined; })();
return { winStart, winEnd, slotMinutes }; return { winStart, winEnd, slotMinutes };
}); });
// If any disponibilidade declares slot_minutes, prefill duration_minutes on the form
try { try {
// persist windows so UI can apply duration-fit filtering
if (mountedRef.current) setAvailabilityWindows(windows);
const candidate = windows.find((w: any) => w.slotMinutes && Number.isFinite(Number(w.slotMinutes))); const candidate = windows.find((w: any) => w.slotMinutes && Number.isFinite(Number(w.slotMinutes)));
if (candidate) { if (candidate) {
const durationVal = Number(candidate.slotMinutes); const durationVal = Number(candidate.slotMinutes);
// Only set if different to avoid unnecessary updates
if ((formData as any).duration_minutes !== durationVal) { if ((formData as any).duration_minutes !== durationVal) {
onFormChange({ ...formData, duration_minutes: durationVal }); onFormChange({ ...formData, duration_minutes: durationVal });
} }
try { setLockedDurationFromSlot(true); } catch (e) {} try { setLockedDurationFromSlot(true); } catch (e) {}
} else { } else {
// no slot_minutes declared -> ensure unlocked
try { setLockedDurationFromSlot(false); } catch (e) {} try { setLockedDurationFromSlot(false); } catch (e) {}
} }
} catch (e) { } catch (e) {
console.debug('[CalendarRegistrationForm] erro ao definir duração automática', e); console.debug('[CalendarRegistrationForm] erro ao definir duração automática', e);
} }
// Keep backend slots that fall inside windows
const existingInWindow = (av.slots || []).filter((s: any) => { const existingInWindow = (av.slots || []).filter((s: any) => {
try { try {
const sd = new Date(s.datetime); const sd = new Date(s.datetime);
@ -428,7 +421,6 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
} catch (e) { return false; } } catch (e) { return false; }
}); });
// Determine global step (minutes) from returned slots, fallback to 30
let stepMinutes = 30; let stepMinutes = 30;
try { try {
const times = (av.slots || []).map((s: any) => new Date(s.datetime).getTime()).sort((a: number, b: number) => a - b); const times = (av.slots || []).map((s: any) => new Date(s.datetime).getTime()).sort((a: number, b: number) => a - b);
@ -437,24 +429,16 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
const d = Math.round((times[i] - times[i - 1]) / 60000); const d = Math.round((times[i] - times[i - 1]) / 60000);
if (d > 0) diffs.push(d); if (d > 0) diffs.push(d);
} }
if (diffs.length) { if (diffs.length) stepMinutes = Math.min(...diffs);
stepMinutes = Math.min(...diffs); } catch (e) {}
}
} catch (e) {
// keep fallback
}
// Generate missing slots per window respecting slot_minutes (if present).
const generatedSet = new Set<string>(); const generatedSet = new Set<string>();
windows.forEach((w: any) => { windows.forEach((w: any) => {
try { try {
const perWindowStep = Number(w.slotMinutes) || stepMinutes; const perWindowStep = Number(w.slotMinutes) || stepMinutes;
const startMs = w.winStart.getTime(); const startMs = w.winStart.getTime();
const endMs = w.winEnd.getTime(); const endMs = w.winEnd.getTime();
// compute last allowed slot start so that start + perWindowStep <= winEnd
const lastStartMs = endMs - perWindowStep * 60000; const lastStartMs = endMs - perWindowStep * 60000;
// backend slots inside this window (ms)
const backendSlotsInWindow = (av.slots || []).filter((s: any) => { const backendSlotsInWindow = (av.slots || []).filter((s: any) => {
try { try {
const sd = new Date(s.datetime); const sd = new Date(s.datetime);
@ -466,14 +450,12 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
}).map((s: any) => new Date(s.datetime).getTime()).sort((a: number, b: number) => a - b); }).map((s: any) => new Date(s.datetime).getTime()).sort((a: number, b: number) => a - b);
if (!backendSlotsInWindow.length) { if (!backendSlotsInWindow.length) {
// generate full window from winStart to lastStartMs
let cursorMs = startMs; let cursorMs = startMs;
while (cursorMs <= lastStartMs) { while (cursorMs <= lastStartMs) {
generatedSet.add(new Date(cursorMs).toISOString()); generatedSet.add(new Date(cursorMs).toISOString());
cursorMs += perWindowStep * 60000; cursorMs += perWindowStep * 60000;
} }
} else { } else {
// generate after last backend slot up to lastStartMs
const lastBackendMs = backendSlotsInWindow[backendSlotsInWindow.length - 1]; const lastBackendMs = backendSlotsInWindow[backendSlotsInWindow.length - 1];
let cursorMs = lastBackendMs + perWindowStep * 60000; let cursorMs = lastBackendMs + perWindowStep * 60000;
while (cursorMs <= lastStartMs) { while (cursorMs <= lastStartMs) {
@ -481,14 +463,10 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
cursorMs += perWindowStep * 60000; cursorMs += perWindowStep * 60000;
} }
} }
} catch (e) { } catch (e) {}
// skip malformed window
}
}); });
// Merge existingInWindow (prefer backend objects) with generated ones
const mergedMap = new Map<string, { datetime: string; available: boolean; slot_minutes?: number }>(); const mergedMap = new Map<string, { datetime: string; available: boolean; slot_minutes?: number }>();
// helper to find window slotMinutes for a given ISO datetime
const findWindowSlotMinutes = (isoDt: string) => { const findWindowSlotMinutes = (isoDt: string) => {
try { try {
const sd = new Date(isoDt); const sd = new Date(isoDt);
@ -516,28 +494,66 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
}); });
const merged = Array.from(mergedMap.values()).sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime()); const merged = Array.from(mergedMap.values()).sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
console.debug('[CalendarRegistrationForm] slots after merge/generated count', merged.length, 'stepMinutes', stepMinutes); if (mountedRef.current) setAvailableSlots(merged || []);
setAvailableSlots(merged || []);
} else { } else {
// No disponibilidade entries for this weekday -> use av.slots as-is if (mountedRef.current) {
setAvailabilityWindows([]);
setAvailableSlots(av.slots || []); setAvailableSlots(av.slots || []);
} }
}
} catch (e) { } catch (e) {
console.warn('[CalendarRegistrationForm] erro ao filtrar por disponibilidades públicas', e); console.warn('[CalendarRegistrationForm] erro ao filtrar por disponibilidades públicas', e);
setAvailableSlots(av.slots || []); if (mountedRef.current) setAvailableSlots(av.slots || []);
} }
} catch (e) { } catch (e) {
console.warn('[CalendarRegistrationForm] falha ao carregar horários disponíveis', e); console.warn('[CalendarRegistrationForm] falha ao carregar horários disponíveis', e);
if (!mounted) return; if (mountedRef.current) setAvailableSlots([]);
setAvailableSlots([]);
} finally { } finally {
if (!mounted) return; if (mountedRef.current) setLoadingSlots(false);
setLoadingSlots(false);
} }
})(); };
return () => { mounted = false; };
// Call when doctor/date/appointmentType change
useEffect(() => {
fetchExceptionsAndSlots();
// note: we intentionally keep the same dependency list to preserve existing behaviour
}, [(formData as any).doctorId, (formData as any).doctor_id, (formData as any).appointmentDate, (formData as any).appointmentType]); }, [(formData as any).doctorId, (formData as any).doctor_id, (formData as any).appointmentDate, (formData as any).appointmentType]);
// Also attempt a mount-time call to cover the case where the form is mounted
// with doctor/date already present (edit flow). This ensures parity with
// the create flow which triggers the requests during user interaction.
useEffect(() => {
const docId = (formData as any).doctorId || (formData as any).doctor_id || null;
const date = (formData as any).appointmentDate || null;
console.debug('[CalendarRegistrationForm] mount-time check formData doctor/date', { doctorId: docId, doctor_id: (formData as any).doctor_id, appointmentDate: date, sampleFormData: formData });
const profName = (formData as any).professionalName || (formData as any).professional || '';
// If we don't have an id but have a professional name, try to find the id from loaded options
if (!docId && profName && doctorOptions && doctorOptions.length) {
const found = doctorOptions.find((d: any) => {
const name = (d.full_name || d.name || '').toLowerCase();
return name && profName.toLowerCase() && (name === profName.toLowerCase() || name.includes(profName.toLowerCase()));
});
if (found) {
// set doctorId on form so the normal effect will run
try { onFormChange({ ...formData, doctorId: String(found.id) }); } catch (e) {}
// Also proactively fetch availability using the discovered id
if (date) {
Promise.resolve().then(() => { if (mountedRef.current) fetchExceptionsAndSlots(String(found.id), date); });
} else {
Promise.resolve().then(() => { if (mountedRef.current) fetchExceptionsAndSlots(String(found.id)); });
}
}
}
if ((docId || ((doctorOptions || []).find((d: any) => (d.full_name || d.name || '').toLowerCase().includes(((formData as any).professionalName || '').toLowerCase())))) && date) {
// schedule microtask so mount effects ordering won't conflict with parent
Promise.resolve().then(() => {
if (mountedRef.current) fetchExceptionsAndSlots();
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => { const handleChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = event.target; const { name, value } = event.target;
@ -618,6 +634,55 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
else effectiveDoctorOptions = doctorOptions || []; else effectiveDoctorOptions = doctorOptions || [];
} }
// derive displayed slots by filtering availableSlots to those that can fit the
// desired duration within any availability window. If no windows are present,
// fall back to availableSlots as-is.
const displayedSlots = (() => {
try {
const duration = Number((formData as any).duration_minutes) || 0;
if (!availabilityWindows || !availabilityWindows.length) return availableSlots || [];
// For each slot, check whether slot start + duration <= window.winEnd
return (availableSlots || []).filter((s) => {
try {
const sd = new Date(s.datetime);
const slotStartMs = sd.getTime();
const slotEndMs = slotStartMs + (duration || (s.slot_minutes || 0)) * 60000;
// find a window that contains the entire appointment (start..end)
return availabilityWindows.some((w) => {
return slotStartMs >= w.winStart.getTime() && slotEndMs <= w.winEnd.getTime();
});
} catch (e) { return false; }
});
} catch (e) {
return availableSlots || [];
}
})();
// Ensure the currently-selected startTime (from formData) is present in the list
// so that editing an existing appointment can keep its original time even if
// the server availability doesn't return it (historic booking).
try {
const date = (formData as any).appointmentDate;
const start = (formData as any).startTime;
if (date && start) {
const [y, m, d] = String(date).split('-').map((n) => Number(n));
const [hh, mm] = String(start).split(':').map((n) => Number(n));
if (![y, m, d, hh, mm].some((n) => Number.isNaN(n))) {
const iso = new Date(y, m - 1, d, hh, mm, 0, 0).toISOString();
const present = (displayedSlots || []).some((s) => s.datetime === iso);
if (!present) {
// find in availableSlots if exists to copy slot_minutes, else synthesize
const found = (availableSlots || []).find((s) => {
try { return new Date(s.datetime).toISOString() === iso; } catch (e) { return false; }
});
const toAdd = found ? { ...found } : { datetime: iso, available: false, slot_minutes: (formData as any).duration_minutes || undefined };
// prepend so current appointment time appears first
displayedSlots.unshift(toAdd as any);
}
}
}
} catch (e) {}
return ( return (
<form className="space-y-8"> <form className="space-y-8">
@ -676,7 +741,6 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
// clear patient selection and also clear doctor/date/time and slots // clear patient selection and also clear doctor/date/time and slots
setFilteredDoctorOptions(null); setFilteredDoctorOptions(null);
setAvailableSlots([]); setAvailableSlots([]);
setPatientOptions(await listarPacientes({ limit: 200 }).catch(() => []));
const newData: any = { ...formData }; const newData: any = { ...formData };
newData.patientId = null; newData.patientId = null;
newData.patientName = ''; newData.patientName = '';
@ -685,7 +749,11 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
newData.appointmentDate = null; newData.appointmentDate = null;
newData.startTime = ''; newData.startTime = '';
newData.endTime = ''; newData.endTime = '';
// update form first so dependent effects (doctor->patients) run
onFormChange(newData); onFormChange(newData);
// then repopulate the patientOptions (fetch may be async)
const pats = await listarPacientes({ limit: 200 }).catch(() => []);
setPatientOptions(pats || []);
} catch (e) {} } catch (e) {}
}} }}
> >
@ -767,7 +835,6 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
onClick={async () => { onClick={async () => {
try { try {
// clear doctor selection and also clear patient/date/time and slots // clear doctor selection and also clear patient/date/time and slots
setPatientOptions(await listarPacientes({ limit: 200 }).catch(() => []));
setAvailableSlots([]); setAvailableSlots([]);
setFilteredDoctorOptions(null); setFilteredDoctorOptions(null);
const newData2: any = { ...formData }; const newData2: any = { ...formData };
@ -778,7 +845,11 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
newData2.appointmentDate = null; newData2.appointmentDate = null;
newData2.startTime = ''; newData2.startTime = '';
newData2.endTime = ''; newData2.endTime = '';
// update form first so effects that clear options don't erase our repopulation
onFormChange(newData2); onFormChange(newData2);
// then repopulate patients list
const pats = await listarPacientes({ limit: 200 }).catch(() => []);
setPatientOptions(pats || []);
} catch (e) {} } catch (e) {}
}} }}
> >
@ -839,11 +910,19 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
// set duration from slot if available // set duration from slot if available
const sel = (availableSlots || []).find((s) => s.datetime === value) as any; const sel = (availableSlots || []).find((s) => s.datetime === value) as any;
const slotMinutes = sel && sel.slot_minutes ? Number(sel.slot_minutes) : null; const slotMinutes = sel && sel.slot_minutes ? Number(sel.slot_minutes) : null;
// compute endTime from duration (slotMinutes or existing duration)
const durationForCalc = slotMinutes || (formData as any).duration_minutes || 0;
const endDt = new Date(dt.getTime() + Number(durationForCalc) * 60000);
const endH = String(endDt.getHours()).padStart(2, '0');
const endM = String(endDt.getMinutes()).padStart(2, '0');
const endStr = `${endH}:${endM}`;
if (slotMinutes) { if (slotMinutes) {
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, duration_minutes: slotMinutes }); onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, duration_minutes: slotMinutes, endTime: endStr });
try { setLockedDurationFromSlot(true); } catch (e) {} try { setLockedDurationFromSlot(true); } catch (e) {}
try { (lastAutoEndRef as any).current = endStr; } catch (e) {}
} else { } else {
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}` }); onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, endTime: endStr });
try { (lastAutoEndRef as any).current = endStr; } catch (e) {}
} }
} catch (e) { } catch (e) {
// noop // noop
@ -948,11 +1027,19 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
const isoDate = dt.toISOString(); const isoDate = dt.toISOString();
const dateOnly = isoDate.split('T')[0]; const dateOnly = isoDate.split('T')[0];
const slotMinutes = s.slot_minutes || null; const slotMinutes = s.slot_minutes || null;
// compute endTime based on duration
const durationForCalc = slotMinutes || (formData as any).duration_minutes || 0;
const endDt = new Date(dt.getTime() + Number(durationForCalc) * 60000);
const endH = String(endDt.getHours()).padStart(2, '0');
const endM = String(endDt.getMinutes()).padStart(2, '0');
const endStr = `${endH}:${endM}`;
if (slotMinutes) { if (slotMinutes) {
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, duration_minutes: Number(slotMinutes) }); onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, duration_minutes: Number(slotMinutes), endTime: endStr });
try { setLockedDurationFromSlot(true); } catch (e) {} try { setLockedDurationFromSlot(true); } catch (e) {}
try { (lastAutoEndRef as any).current = endStr; } catch (e) {}
} else { } else {
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}` }); onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, endTime: endStr });
try { (lastAutoEndRef as any).current = endStr; } catch (e) {}
} }
}} }}
> >