fix-appoinments-endpoints
This commit is contained in:
parent
7c077fbf45
commit
905caa14ad
@ -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>
|
||||||
|
|||||||
@ -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) {}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user