Merge pull request 'feature/add-appointments-endpoint' (#54) from feature/add-appointments-endpoint into develop

Reviewed-on: #54
This commit is contained in:
M-Gabrielly 2025-10-21 03:17:50 +00:00
commit bb6dbe4841
3 changed files with 486 additions and 341 deletions

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,30 +331,49 @@ 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();
list = list.filter((a) => {
const patient = String(a.patient || '').toLowerCase();
const professional = String(a.professional || '').toLowerCase();
const pid = String(a.patient_id || '').toLowerCase();
const aid = String(a.id || '').toLowerCase();
return (
patient.includes(q) ||
professional.includes(q) ||
pid === q ||
aid === q
);
});
} }
const q = trimmed.toLowerCase(); // status filter
const localMatches = (originalAppointments || []).filter((a) => { if (selectedStatus && selectedStatus !== 'all') {
const patient = String(a.patient || '').toLowerCase(); list = list.filter((a) => String(a.status || '').toLowerCase() === String(selectedStatus).toLowerCase());
const professional = String(a.professional || '').toLowerCase(); }
const pid = String(a.patient_id || '').toLowerCase();
const aid = String(a.id || '').toLowerCase();
return (
patient.includes(q) ||
professional.includes(q) ||
pid === q ||
aid === q
);
});
setAppointments(localMatches as any[]); // 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

@ -849,7 +849,23 @@ const ProfissionalPage = () => {
try { try {
if (isMaybeId(term)) { if (isMaybeId(term)) {
try { try {
const r = await buscarRelatorioPorId(term); let r: any = null;
// Try direct API lookup first
try {
r = await buscarRelatorioPorId(term);
} catch (e) {
console.warn('[SearchBox] buscarRelatorioPorId failed, will try loadReportById fallback', e);
}
// Fallback: use hook loader if direct API didn't return
if (!r) {
try {
r = await loadReportById(term);
} catch (e) {
console.warn('[SearchBox] loadReportById fallback failed', e);
}
}
if (r) { if (r) {
// If token exists, attempt batch enrichment like useReports // If token exists, attempt batch enrichment like useReports
const enriched: any = { ...r }; const enriched: any = { ...r };
@ -935,8 +951,14 @@ const ProfissionalPage = () => {
const handleClear = async () => { const handleClear = async () => {
setSearchTerm(''); setSearchTerm('');
await loadReports(); try {
setLaudos(reports || []); // Reuse the same logic as initial load so Clear restores the doctor's assigned laudos
await loadAssignedLaudos();
} catch (err) {
console.warn('[SearchBox] erro ao restaurar laudos do médico ao limpar busca:', err);
// Safe fallback to whatever reports are available
setLaudos(reports || []);
}
}; };
return ( return (
@ -965,82 +987,44 @@ const ProfissionalPage = () => {
); );
} }
// carregar laudos ao montar - somente dos pacientes atribuídos ao médico logado // helper to load laudos for the patients assigned to the logged-in user
useEffect(() => { const loadAssignedLaudos = async () => {
let mounted = true; try {
(async () => { const assignments = await import('@/lib/assignment').then(m => m.listAssignmentsForUser(user?.id || ''));
const patientIds = Array.isArray(assignments) ? assignments.map(a => String(a.patient_id)).filter(Boolean) : [];
if (patientIds.length === 0) {
setLaudos([]);
return;
}
try { try {
// obter assignments para o usuário logado const reportsMod = await import('@/lib/reports');
const assignments = await import('@/lib/assignment').then(m => m.listAssignmentsForUser(user?.id || '')); if (typeof reportsMod.listarRelatoriosPorPacientes === 'function') {
const patientIds = Array.isArray(assignments) ? assignments.map(a => String(a.patient_id)).filter(Boolean) : []; const batch = await reportsMod.listarRelatoriosPorPacientes(patientIds);
const mineOnly = (batch || []).filter((r: any) => {
const requester = ((r.requested_by ?? r.created_by ?? r.executante ?? r.requestedBy ?? r.createdBy) || '').toString();
return user?.id && requester && requester === user.id;
});
if (patientIds.length === 0) { const enriched = await (async (reportsArr: any[]) => {
if (mounted) setLaudos([]); if (!reportsArr || !reportsArr.length) return reportsArr;
return; const pids = reportsArr.map(r => String(getReportPatientId(r))).filter(Boolean);
} if (!pids.length) return reportsArr;
try {
// Tentar carregar todos os relatórios em uma única chamada usando in.(...) const patients = await buscarPacientesPorIds(pids);
try { const map = new Map((patients || []).map((p: any) => [String(p.id), p]));
const reportsMod = await import('@/lib/reports'); return reportsArr.map((r: any) => {
if (typeof reportsMod.listarRelatoriosPorPacientes === 'function') { const pid = String(getReportPatientId(r));
const batch = await reportsMod.listarRelatoriosPorPacientes(patientIds); return { ...r, paciente: r.paciente ?? map.get(pid) ?? r.paciente } as any;
// Filtrar apenas relatórios criados/solicitados por este usuário (evita mostrar laudos de outros médicos) });
const mineOnly = (batch || []).filter((r: any) => { } catch (e) {
const requester = ((r.requested_by ?? r.created_by ?? r.executante ?? r.requestedBy ?? r.createdBy) || '').toString(); return reportsArr;
return user?.id && requester && requester === user.id;
});
// Enrich reports with paciente objects so UI shows name/cpf immediately
const enriched = await (async (reportsArr: any[]) => {
if (!reportsArr || !reportsArr.length) return reportsArr;
const pids = reportsArr.map(r => String(getReportPatientId(r))).filter(Boolean);
if (!pids.length) return reportsArr;
try {
const patients = await buscarPacientesPorIds(pids);
const map = new Map((patients || []).map((p: any) => [String(p.id), p]));
return reportsArr.map(r => {
const pid = String(getReportPatientId(r));
return { ...r, paciente: r.paciente ?? map.get(pid) ?? r.paciente };
});
} catch (e) {
return reportsArr;
}
})(mineOnly);
if (mounted) setLaudos(enriched || []);
} else {
// fallback: 请求 por paciente individual
const allReports: any[] = [];
for (const pid of patientIds) {
try {
const rels = await import('@/lib/reports').then(m => m.listarRelatoriosPorPaciente(pid));
if (Array.isArray(rels) && rels.length) {
// filtrar por autor (requested_by / created_by / executante)
const mine = rels.filter((r: any) => {
const requester = ((r.requested_by ?? r.created_by ?? r.executante ?? r.requestedBy ?? r.createdBy) || '').toString();
return user?.id && requester && requester === user.id;
});
if (mine.length) allReports.push(...mine);
}
} catch (err) {
console.warn('[LaudoManager] falha ao carregar relatórios para paciente', pid, err);
}
} }
// enrich fallback results too })(mineOnly);
const enrichedAll = await (async (reportsArr: any[]) => { setLaudos(enriched || []);
if (!reportsArr || !reportsArr.length) return reportsArr; return;
const pids = reportsArr.map(r => String(getReportPatientId(r))).filter(Boolean); } else {
if (!pids.length) return reportsArr;
try {
const patients = await buscarPacientesPorIds(pids);
const map = new Map((patients || []).map((p: any) => [String(p.id), p]));
return reportsArr.map(r => ({ ...r, paciente: r.paciente ?? map.get(String(getReportPatientId(r))) ?? r.paciente }));
} catch (e) {
return reportsArr;
}
})(allReports);
if (mounted) setLaudos(enrichedAll);
}
} catch (err) {
console.warn('[LaudoManager] erro ao carregar relatórios em batch, tentando por paciente individual', err);
const allReports: any[] = []; const allReports: any[] = [];
for (const pid of patientIds) { for (const pid of patientIds) {
try { try {
@ -1052,10 +1036,11 @@ const ProfissionalPage = () => {
}); });
if (mine.length) allReports.push(...mine); if (mine.length) allReports.push(...mine);
} }
} catch (e) { } catch (err) {
console.warn('[LaudoManager] falha ao carregar relatórios para paciente', pid, e); console.warn('[LaudoManager] falha ao carregar relatórios para paciente', pid, err);
} }
} }
const enrichedAll = await (async (reportsArr: any[]) => { const enrichedAll = await (async (reportsArr: any[]) => {
if (!reportsArr || !reportsArr.length) return reportsArr; if (!reportsArr || !reportsArr.length) return reportsArr;
const pids = reportsArr.map(r => String(getReportPatientId(r))).filter(Boolean); const pids = reportsArr.map(r => String(getReportPatientId(r))).filter(Boolean);
@ -1063,17 +1048,58 @@ const ProfissionalPage = () => {
try { try {
const patients = await buscarPacientesPorIds(pids); const patients = await buscarPacientesPorIds(pids);
const map = new Map((patients || []).map((p: any) => [String(p.id), p])); const map = new Map((patients || []).map((p: any) => [String(p.id), p]));
return reportsArr.map(r => ({ ...r, paciente: r.paciente ?? map.get(String(getReportPatientId(r))) ?? r.paciente })); return reportsArr.map((r: any) => ({ ...r, paciente: r.paciente ?? map.get(String(getReportPatientId(r))) ?? r.paciente } as any));
} catch (e) { } catch (e) {
return reportsArr; return reportsArr;
} }
})(allReports); })(allReports);
if (mounted) setLaudos(enrichedAll); setLaudos(enrichedAll);
return;
} }
} catch (e) { } catch (err) {
console.warn('[LaudoManager] erro ao carregar laudos para pacientes atribuídos:', e); console.warn('[LaudoManager] erro ao carregar relatórios em batch, tentando por paciente individual', err);
if (mounted) setLaudos(reports || []); const allReports: any[] = [];
for (const pid of patientIds) {
try {
const rels = await import('@/lib/reports').then(m => m.listarRelatoriosPorPaciente(pid));
if (Array.isArray(rels) && rels.length) {
const mine = rels.filter((r: any) => {
const requester = ((r.requested_by ?? r.created_by ?? r.executante ?? r.requestedBy ?? r.createdBy) || '').toString();
return user?.id && requester && requester === user.id;
});
if (mine.length) allReports.push(...mine);
}
} catch (e) {
console.warn('[LaudoManager] falha ao carregar relatórios para paciente', pid, e);
}
}
const enrichedAll = await (async (reportsArr: any[]) => {
if (!reportsArr || !reportsArr.length) return reportsArr;
const pids = reportsArr.map(r => String(getReportPatientId(r))).filter(Boolean);
if (!pids.length) return reportsArr;
try {
const patients = await buscarPacientesPorIds(pids);
const map = new Map((patients || []).map((p: any) => [String(p.id), p]));
return reportsArr.map((r: any) => ({ ...r, paciente: r.paciente ?? map.get(String(getReportPatientId(r))) ?? r.paciente } as any));
} catch (e) {
return reportsArr;
}
})(allReports);
setLaudos(enrichedAll);
return;
} }
} catch (e) {
console.warn('[LaudoManager] erro ao carregar laudos para pacientes atribuídos:', e);
setLaudos(reports || []);
}
};
// carregar laudos ao montar - somente dos pacientes atribuídos ao médico logado
useEffect(() => {
let mounted = true;
(async () => {
// call the helper and bail if the component unmounted during async work
await loadAssignedLaudos();
})(); })();
return () => { mounted = false; }; return () => { mounted = false; };
}, [user?.id]); }, [user?.id]);

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,269 +276,284 @@ 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) {
const blocking = (exceptions || []).find((e: any) => e && e.kind === 'bloqueio'); const blocking = (exceptions || []).find((e: any) => e && e.kind === 'bloqueio');
if (blocking) { if (blocking) {
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 (mountedRef.current) {
if (!mounted) return;
setAvailableSlots([]); setAvailableSlots([]);
setLoadingSlots(false); setLoadingSlots(false);
return;
} }
return;
} }
} catch (exCheckErr) {
// If the exceptions check fails for network reasons, proceed to availability fetch
console.warn('[CalendarRegistrationForm] listarExcecoes falhou, continuando para getAvailableSlots', exCheckErr);
} }
console.debug('[CalendarRegistrationForm] getAvailableSlots - params', { docId, date, appointmentType: formData.appointmentType }); } catch (exCheckErr) {
console.debug('[CalendarRegistrationForm] doctorOptions count', (doctorOptions || []).length, 'selectedDoctorId', docId, 'doctorOptions sample', (doctorOptions || []).slice(0,3)); console.warn('[CalendarRegistrationForm] listarExcecoes falhou, continuando para getAvailableSlots', exCheckErr);
// Build start/end as local day bounds from YYYY-MM-DD to avoid }
// timezone/parsing issues (sending incorrect UTC offsets that shift
// the requested day to the previous/next calendar day). console.debug('[CalendarRegistrationForm] getAvailableSlots - params', { docId, date, appointmentType: formData.appointmentType });
// Expect `date` to be in format 'YYYY-MM-DD'. Parse explicitly.
let start: Date; // Build local start/end for the day
let end: Date; let start: Date;
try { let end: Date;
const parts = String(date).split('-').map((p) => Number(p)); try {
if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) { const parts = String(date).split('-').map((p) => Number(p));
const [y, m, d] = parts; if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) {
// new Date(y, m-1, d, hh, mm, ss, ms) constructs a date in the const [y, m, d] = parts;
// local timezone at the requested hour. toISOString() will then start = new Date(y, m - 1, d, 0, 0, 0, 0);
// represent that local instant in UTC which is what the server end = new Date(y, m - 1, d, 23, 59, 59, 999);
// expects for availability checks across timezones. } else {
start = new Date(y, m - 1, d, 0, 0, 0, 0);
end = new Date(y, m - 1, d, 23, 59, 59, 999);
} else {
// fallback to previous logic if parsing fails
start = new Date(date);
start.setHours(0,0,0,0);
end = new Date(date);
end.setHours(23,59,59,999);
}
} 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' }); } catch (err) {
if (!mounted) return; start = new Date(date);
console.debug('[CalendarRegistrationForm] getAvailableSlots - response slots count', (av && av.slots && av.slots.length) || 0, av); start.setHours(0,0,0,0);
end = new Date(date);
end.setHours(23,59,59,999);
}
// Try to restrict the returned slots to the doctor's public availability windows const av = await getAvailableSlots({ doctor_id: String(docId), start_date: start.toISOString(), end_date: end.toISOString(), appointment_type: formData.appointmentType || 'presencial' });
try { if (!mountedRef.current) return;
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 {
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'], 2: ['2', 'tue', 'tuesday', 'terca', 'terça', 'terça-feira'],
2: ['2', 'tue', 'tuesday', 'terca', 'terça', 'terça-feira'], 3: ['3', 'wed', 'wednesday', 'quarta', 'quarta-feira'],
3: ['3', 'wed', 'wednesday', 'quarta', 'quarta-feira'], 4: ['4', 'thu', 'thursday', 'quinta', 'quinta-feira'],
4: ['4', 'thu', 'thursday', 'quinta', 'quinta-feira'], 5: ['5', 'fri', 'friday', 'sexta', 'sexta-feira'],
5: ['5', 'fri', 'friday', 'sexta', 'sexta-feira'], 6: ['6', 'sat', 'saturday', 'sabado', 'sábado']
6: ['6', 'sat', 'saturday', 'sabado', 'sábado'] };
}; 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; if (typeof d.weekday === 'number' && d.weekday === weekdayNumber) return true;
// sometimes API returns numbers as integers if (typeof d.day_of_week === 'number' && d.day_of_week === weekdayNumber) return true;
if (typeof d.weekday === 'number' && d.weekday === weekdayNumber) return true; return false;
if (typeof d.day_of_week === 'number' && d.day_of_week === weekdayNumber) return true; } catch (e) { return false; }
return false; });
if (matched && matched.length) {
const windows = matched.map((d: any) => {
const parseTime = (t?: string) => {
if (!t) return { hh: 0, mm: 0, ss: 0 };
const parts = String(t).split(':').map((p) => Number(p));
return { hh: parts[0] || 0, mm: parts[1] || 0, ss: parts[2] || 0 };
};
const s = parseTime(d.start_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 winEnd = new Date(start.getFullYear(), start.getMonth(), start.getDate(), e2.hh, e2.mm, e2.ss || 0, 999);
const slotMinutes = (() => { const n = Number(d.slot_minutes ?? d.slot_minutes_minutes ?? NaN); return Number.isFinite(n) ? n : undefined; })();
return { winStart, winEnd, slotMinutes };
});
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)));
if (candidate) {
const durationVal = Number(candidate.slotMinutes);
if ((formData as any).duration_minutes !== durationVal) {
onFormChange({ ...formData, duration_minutes: durationVal });
}
try { setLockedDurationFromSlot(true); } catch (e) {}
} else {
try { setLockedDurationFromSlot(false); } catch (e) {}
}
} catch (e) {
console.debug('[CalendarRegistrationForm] erro ao definir duração automática', e);
}
const existingInWindow = (av.slots || []).filter((s: any) => {
try {
const sd = new Date(s.datetime);
const slotMinutes = sd.getHours() * 60 + sd.getMinutes();
return windows.some((w: any) => {
const ws = w.winStart;
const we = w.winEnd;
const winStartMinutes = ws.getHours() * 60 + ws.getMinutes();
const winEndMinutes = we.getHours() * 60 + we.getMinutes();
return slotMinutes >= winStartMinutes && slotMinutes <= winEndMinutes;
});
} catch (e) { return false; } } catch (e) { return false; }
}); });
console.debug('[CalendarRegistrationForm] disponibilidades fetched', disponibilidades, 'matched for weekday', weekdayNumber, matched);
if (matched && matched.length) { let stepMinutes = 30;
// Build windows from matched disponibilidades and filter av.slots try {
const windows = matched.map((d: any) => { const times = (av.slots || []).map((s: any) => new Date(s.datetime).getTime()).sort((a: number, b: number) => a - b);
// d.start_time may be '09:00:00' or '09:00' const diffs: number[] = [];
const parseTime = (t?: string) => { for (let i = 1; i < times.length; i++) {
if (!t) return { hh: 0, mm: 0, ss: 0 }; const d = Math.round((times[i] - times[i - 1]) / 60000);
const parts = String(t).split(':').map((p) => Number(p)); if (d > 0) diffs.push(d);
return { hh: parts[0] || 0, mm: parts[1] || 0, ss: parts[2] || 0 }; }
}; if (diffs.length) stepMinutes = Math.min(...diffs);
const s = parseTime(d.start_time); } catch (e) {}
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 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;
return { winStart, winEnd, slotMinutes };
});
// If any disponibilidade declares slot_minutes, prefill duration_minutes on the form const generatedSet = new Set<string>();
windows.forEach((w: any) => {
try { try {
const candidate = windows.find((w: any) => w.slotMinutes && Number.isFinite(Number(w.slotMinutes))); const perWindowStep = Number(w.slotMinutes) || stepMinutes;
if (candidate) { const startMs = w.winStart.getTime();
const durationVal = Number(candidate.slotMinutes); const endMs = w.winEnd.getTime();
// Only set if different to avoid unnecessary updates const lastStartMs = endMs - perWindowStep * 60000;
if ((formData as any).duration_minutes !== durationVal) { const backendSlotsInWindow = (av.slots || []).filter((s: any) => {
onFormChange({ ...formData, duration_minutes: durationVal }); try {
const sd = new Date(s.datetime);
const sm = sd.getHours() * 60 + sd.getMinutes();
const wmStart = w.winStart.getHours() * 60 + w.winStart.getMinutes();
const wmEnd = w.winEnd.getHours() * 60 + w.winEnd.getMinutes();
return sm >= wmStart && sm <= wmEnd;
} catch (e) { return false; }
}).map((s: any) => new Date(s.datetime).getTime()).sort((a: number, b: number) => a - b);
if (!backendSlotsInWindow.length) {
let cursorMs = startMs;
while (cursorMs <= lastStartMs) {
generatedSet.add(new Date(cursorMs).toISOString());
cursorMs += perWindowStep * 60000;
} }
try { setLockedDurationFromSlot(true); } catch (e) {}
} else { } else {
// no slot_minutes declared -> ensure unlocked const lastBackendMs = backendSlotsInWindow[backendSlotsInWindow.length - 1];
try { setLockedDurationFromSlot(false); } catch (e) {} let cursorMs = lastBackendMs + perWindowStep * 60000;
} while (cursorMs <= lastStartMs) {
} catch (e) { generatedSet.add(new Date(cursorMs).toISOString());
console.debug('[CalendarRegistrationForm] erro ao definir duração automática', e); cursorMs += perWindowStep * 60000;
}
// Keep backend slots that fall inside windows
const existingInWindow = (av.slots || []).filter((s: any) => {
try {
const sd = new Date(s.datetime);
const slotMinutes = sd.getHours() * 60 + sd.getMinutes();
return windows.some((w: any) => {
const ws = w.winStart;
const we = w.winEnd;
const winStartMinutes = ws.getHours() * 60 + ws.getMinutes();
const winEndMinutes = we.getHours() * 60 + we.getMinutes();
return slotMinutes >= winStartMinutes && slotMinutes <= winEndMinutes;
});
} catch (e) { return false; }
});
// Determine global step (minutes) from returned slots, fallback to 30
let stepMinutes = 30;
try {
const times = (av.slots || []).map((s: any) => new Date(s.datetime).getTime()).sort((a: number, b: number) => a - b);
const diffs: number[] = [];
for (let i = 1; i < times.length; i++) {
const d = Math.round((times[i] - times[i - 1]) / 60000);
if (d > 0) diffs.push(d);
}
if (diffs.length) {
stepMinutes = Math.min(...diffs);
}
} catch (e) {
// keep fallback
}
// Generate missing slots per window respecting slot_minutes (if present).
const generatedSet = new Set<string>();
windows.forEach((w: any) => {
try {
const perWindowStep = Number(w.slotMinutes) || stepMinutes;
const startMs = w.winStart.getTime();
const endMs = w.winEnd.getTime();
// compute last allowed slot start so that start + perWindowStep <= winEnd
const lastStartMs = endMs - perWindowStep * 60000;
// backend slots inside this window (ms)
const backendSlotsInWindow = (av.slots || []).filter((s: any) => {
try {
const sd = new Date(s.datetime);
const sm = sd.getHours() * 60 + sd.getMinutes();
const wmStart = w.winStart.getHours() * 60 + w.winStart.getMinutes();
const wmEnd = w.winEnd.getHours() * 60 + w.winEnd.getMinutes();
return sm >= wmStart && sm <= wmEnd;
} catch (e) { return false; }
}).map((s: any) => new Date(s.datetime).getTime()).sort((a: number, b: number) => a - b);
if (!backendSlotsInWindow.length) {
// generate full window from winStart to lastStartMs
let cursorMs = startMs;
while (cursorMs <= lastStartMs) {
generatedSet.add(new Date(cursorMs).toISOString());
cursorMs += perWindowStep * 60000;
}
} else {
// generate after last backend slot up to lastStartMs
const lastBackendMs = backendSlotsInWindow[backendSlotsInWindow.length - 1];
let cursorMs = lastBackendMs + perWindowStep * 60000;
while (cursorMs <= lastStartMs) {
generatedSet.add(new Date(cursorMs).toISOString());
cursorMs += perWindowStep * 60000;
}
} }
} catch (e) {
// skip malformed window
} }
}); } catch (e) {}
});
// 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 }>(); const findWindowSlotMinutes = (isoDt: string) => {
// helper to find window slotMinutes for a given ISO datetime try {
const findWindowSlotMinutes = (isoDt: string) => { const sd = new Date(isoDt);
try { const sm = sd.getHours() * 60 + sd.getMinutes();
const sd = new Date(isoDt); const w = windows.find((win: any) => {
const sm = sd.getHours() * 60 + sd.getMinutes(); const ws = win.winStart;
const w = windows.find((win: any) => { const we = win.winEnd;
const ws = win.winStart; const winStartMinutes = ws.getHours() * 60 + ws.getMinutes();
const we = win.winEnd; const winEndMinutes = we.getHours() * 60 + we.getMinutes();
const winStartMinutes = ws.getHours() * 60 + ws.getMinutes(); return sm >= winStartMinutes && sm <= winEndMinutes;
const winEndMinutes = we.getHours() * 60 + we.getMinutes(); });
return sm >= winStartMinutes && sm <= winEndMinutes; return w && w.slotMinutes ? Number(w.slotMinutes) : null;
}); } catch (e) { return null; }
return w && w.slotMinutes ? Number(w.slotMinutes) : null; };
} catch (e) { return null; }
};
(existingInWindow || []).forEach((s: any) => { (existingInWindow || []).forEach((s: any) => {
const sm = findWindowSlotMinutes(s.datetime); const sm = findWindowSlotMinutes(s.datetime);
mergedMap.set(s.datetime, sm ? { ...s, slot_minutes: sm } : { ...s }); mergedMap.set(s.datetime, sm ? { ...s, slot_minutes: sm } : { ...s });
}); });
Array.from(generatedSet).forEach((dt) => { Array.from(generatedSet).forEach((dt) => {
if (!mergedMap.has(dt)) { if (!mergedMap.has(dt)) {
const sm = findWindowSlotMinutes(dt) || stepMinutes; const sm = findWindowSlotMinutes(dt) || stepMinutes;
mergedMap.set(dt, { datetime: dt, available: true, slot_minutes: sm }); mergedMap.set(dt, { datetime: dt, available: true, slot_minutes: sm });
} }
}); });
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 { if (mountedRef.current) {
// No disponibilidade entries for this weekday -> use av.slots as-is setAvailabilityWindows([]);
setAvailableSlots(av.slots || []); setAvailableSlots(av.slots || []);
} }
} catch (e) {
console.warn('[CalendarRegistrationForm] erro ao filtrar por disponibilidades públicas', e);
setAvailableSlots(av.slots || []);
} }
} catch (e) { } catch (e) {
console.warn('[CalendarRegistrationForm] falha ao carregar horários disponíveis', e); console.warn('[CalendarRegistrationForm] erro ao filtrar por disponibilidades públicas', e);
if (!mounted) return; if (mountedRef.current) setAvailableSlots(av.slots || []);
setAvailableSlots([]);
} finally {
if (!mounted) return;
setLoadingSlots(false);
} }
})(); } catch (e) {
return () => { mounted = false; }; console.warn('[CalendarRegistrationForm] falha ao carregar horários disponíveis', e);
if (mountedRef.current) setAvailableSlots([]);
} finally {
if (mountedRef.current) setLoadingSlots(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,12 +1027,20 @@ 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;
if (slotMinutes) { // compute endTime based on duration
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, duration_minutes: Number(slotMinutes) }); const durationForCalc = slotMinutes || (formData as any).duration_minutes || 0;
try { setLockedDurationFromSlot(true); } catch (e) {} const endDt = new Date(dt.getTime() + Number(durationForCalc) * 60000);
} else { const endH = String(endDt.getHours()).padStart(2, '0');
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}` }); const endM = String(endDt.getMinutes()).padStart(2, '0');
} const endStr = `${endH}:${endM}`;
if (slotMinutes) {
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, duration_minutes: Number(slotMinutes), endTime: endStr });
try { setLockedDurationFromSlot(true); } catch (e) {}
try { (lastAutoEndRef as any).current = endStr; } catch (e) {}
} else {
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, endTime: endStr });
try { (lastAutoEndRef as any).current = endStr; } catch (e) {}
}
}} }}
> >
{label} {label}